package state import ( "encoding/json" "slices" "strings" "sync" ) type Manager struct { mu sync.RWMutex rooms map[string]*Room store *DiskStore } func NewManager(dataPath string) (*Manager, error) { store, err := NewDiskStore(dataPath) if err != nil { return nil, err } manager := &Manager{ rooms: make(map[string]*Room), store: store, } if loadErr := manager.loadFromDisk(); loadErr != nil { return nil, loadErr } return manager, nil } 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() settings := RoomSettings{ RoomName: roomName, MaxPeople: maxPeople, Cards: cards, AllowSpectators: input.AllowSpectators, AnonymousVoting: input.AnonymousVoting, AutoReset: input.AutoReset, 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, 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, }, subscribers: map[string]*subscriber{}, } 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, 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 } existing.Username = username existing.Connected = true existing.UpdatedAt = now if isAdminByToken { existing.IsAdmin = true } room.UpdatedAt = now if err := m.store.Save(room); err != nil { return JoinRoomResult{}, err } go m.broadcastRoom(room.ID) return JoinRoomResult{ ParticipantID: existing.ID, 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(), Username: username, Role: role, IsAdmin: isAdminByToken, Connected: true, HasVoted: false, JoinedAt: now, UpdatedAt: now, } room.Participants[participant.ID] = participant room.UpdatedAt = now if err := m.store.Save(room); err != nil { return JoinRoomResult{}, err } go m.broadcastRoom(room.ID) return JoinRoomResult{ ParticipantID: participant.ID, IsAdmin: participant.IsAdmin, Role: participant.Role, Username: participant.Username, }, nil } func (m *Manager) LeaveRoom(roomID, participantID string) error { room, err := m.getRoom(roomID) if err != nil { return err } room.mu.Lock() defer room.mu.Unlock() participant, ok := room.Participants[participantID] if !ok { return ErrParticipantNotFound } participant.Connected = false participant.UpdatedAt = nowUTC() room.UpdatedAt = nowUTC() if err := m.store.Save(room); err != nil { return err } go m.broadcastRoom(room.ID) return nil } func (m *Manager) CastVote(roomID, participantID, card string) error { room, err := m.getRoom(roomID) if err != nil { return err } room.mu.Lock() defer room.mu.Unlock() participant, ok := room.Participants[participantID] if !ok { return ErrParticipantNotFound } if participant.Role != RoleParticipant { return ErrUnauthorized } normalizedCard := normalizeCard(card) if normalizedCard == "" || !slices.Contains(room.Settings.Cards, normalizedCard) { return ErrInvalidCard } if room.Round.Revealed { if room.Settings.AutoReset { m.resetVotesLocked(room) } else { return ErrUnauthorized } } participant.HasVoted = true participant.VoteValue = normalizedCard participant.UpdatedAt = nowUTC() room.UpdatedAt = nowUTC() if room.Settings.RevealMode == RevealModeAutoAll && allActiveParticipantsVoted(room) { room.Round.Revealed = true } if err := m.store.Save(room); err != nil { return err } go m.broadcastRoom(room.ID) return nil } func (m *Manager) RevealVotes(roomID, participantID string) error { room, err := m.getRoom(roomID) if err != nil { return err } room.mu.Lock() defer room.mu.Unlock() participant, ok := room.Participants[participantID] if !ok { return ErrParticipantNotFound } if !participant.IsAdmin { return ErrUnauthorized } room.Round.Revealed = true room.UpdatedAt = nowUTC() if err := m.store.Save(room); err != nil { return err } go m.broadcastRoom(room.ID) return nil } func (m *Manager) ResetVotes(roomID, participantID string) error { room, err := m.getRoom(roomID) if err != nil { return err } room.mu.Lock() defer room.mu.Unlock() participant, ok := room.Participants[participantID] if !ok { return ErrParticipantNotFound } if !participant.IsAdmin { return ErrUnauthorized } m.resetVotesLocked(room) room.UpdatedAt = nowUTC() if err := m.store.Save(room); err != nil { return err } go m.broadcastRoom(room.ID) return nil } func (m *Manager) Subscribe(roomID, participantID string) (<-chan []byte, []byte, func(), error) { room, err := m.getRoom(roomID) if err != nil { return nil, nil, nil, err } room.mu.Lock() participant, ok := room.Participants[participantID] if !ok { room.mu.Unlock() return nil, nil, nil, ErrParticipantNotFound } 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 { p.Connected = false p.UpdatedAt = nowUTC() roomRef.UpdatedAt = nowUTC() _ = 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) loadFromDisk() error { persistedRooms, err := m.store.LoadAll() if err != nil { return err } for _, persisted := range persistedRooms { room := &Room{ ID: persisted.ID, AdminToken: persisted.AdminToken, CreatedAt: persisted.CreatedAt, UpdatedAt: persisted.UpdatedAt, Settings: persisted.Settings, Round: persisted.Round, Participants: make(map[string]*Participant, len(persisted.Participants)), subscribers: map[string]*subscriber{}, } for _, participant := range persisted.Participants { participant.Connected = false room.Participants[participant.ID] = participant } m.rooms[room.ID] = room } return nil } func (room *Room) toPersisted() persistedRoom { participants := make([]*Participant, 0, len(room.Participants)) for _, participant := range sortParticipants(room.Participants) { clone := *participant participants = append(participants, &clone) } return persistedRoom{ ID: room.ID, AdminToken: room.AdminToken, CreatedAt: room.CreatedAt, UpdatedAt: room.UpdatedAt, Settings: room.Settings, Round: room.Round, Participants: participants, } } 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, 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 } 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: } } }