Files
scrum-solitare/src/state/manager.go

855 lines
21 KiB
Go
Raw Normal View History

2026-03-05 22:08:06 +02:00
package state
import (
"encoding/json"
2026-03-05 22:41:16 +02:00
"fmt"
2026-03-07 01:16:39 +02:00
"log"
2026-03-05 22:08:06 +02:00
"slices"
"strings"
"sync"
2026-03-07 01:16:39 +02:00
"time"
2026-03-05 22:08:06 +02:00
)
2026-03-05 22:41:16 +02:00
const (
2026-03-07 01:19:37 +02:00
defaultMaxActivityLogEntries = 400
defaultAdminLogBroadcastLimit = 200
defaultStaleRoomCleanupInterval = 5 * time.Minute
defaultStaleRoomTTL = 30 * time.Minute
2026-03-05 22:41:16 +02:00
)
2026-03-05 22:08:06 +02:00
type Manager struct {
mu sync.RWMutex
rooms map[string]*Room
store *DiskStore
2026-03-07 01:19:37 +02:00
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
2026-03-05 22:08:06 +02:00
}
2026-03-07 01:19:37 +02:00
func NewManager(dataPath string, opts ManagerOptions) (*Manager, error) {
2026-03-05 22:08:06 +02:00
store, err := NewDiskStore(dataPath)
if err != nil {
return nil, err
}
2026-03-07 01:19:37 +02:00
normalizedOpts := normalizeManagerOptions(opts)
2026-03-05 22:08:06 +02:00
manager := &Manager{
rooms: make(map[string]*Room),
store: store,
2026-03-07 01:19:37 +02:00
maxActivityLogEntries: normalizedOpts.MaxActivityLogEntries,
adminLogBroadcastLimit: normalizedOpts.AdminLogBroadcastLimit,
staleRoomCleanupInterval: normalizedOpts.StaleRoomCleanupInterval,
staleRoomTTL: normalizedOpts.StaleRoomTTL,
2026-03-05 22:08:06 +02:00
}
if loadErr := manager.loadFromDisk(); loadErr != nil {
return nil, loadErr
}
2026-03-07 01:16:39 +02:00
manager.startCleanupLoop()
2026-03-05 22:08:06 +02:00
return manager, nil
}
2026-03-07 01:16:39 +02:00
func (m *Manager) startCleanupLoop() {
2026-03-07 01:19:37 +02:00
ticker := time.NewTicker(m.staleRoomCleanupInterval)
2026-03-07 01:16:39 +02:00
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)
2026-03-07 01:19:37 +02:00
recentlyActive := now.Sub(room.UpdatedAt) < m.staleRoomTTL
2026-03-07 01:16:39 +02:00
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()
2026-03-07 01:19:37 +02:00
if hasConnectedParticipantsLocked(current) || now.Sub(current.UpdatedAt) < m.staleRoomTTL || len(current.subscribers) > 0 {
2026-03-07 01:16:39 +02:00
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()
}
}
}
2026-03-05 22:08:06 +02:00
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()
2026-03-06 12:30:05 +02:00
allowVoteChange := true
if input.AllowVoteChange != nil {
allowVoteChange = *input.AllowVoteChange
}
2026-03-05 22:08:06 +02:00
settings := RoomSettings{
RoomName: roomName,
MaxPeople: maxPeople,
Cards: cards,
AllowSpectators: input.AllowSpectators,
AnonymousVoting: input.AnonymousVoting,
AutoReset: input.AutoReset,
2026-03-06 12:30:05 +02:00
AllowVoteChange: allowVoteChange,
2026-03-05 22:08:06 +02:00
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{
2026-03-06 12:30:05 +02:00
ID: creatorID,
SessionToken: randomHex(24),
2026-03-05 22:08:06 +02:00
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,
},
2026-03-05 22:41:16 +02:00
ActivityLog: []ActivityLogEntry{},
2026-03-05 22:08:06 +02:00
subscribers: map[string]*subscriber{},
}
2026-03-05 22:41:16 +02:00
m.appendActivityLogLocked(room, "%s created the room as admin.", creator.Username)
2026-03-05 22:08:06 +02:00
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,
2026-03-06 12:30:05 +02:00
CreatorSessionToken: creator.SessionToken,
2026-03-05 22:08:06 +02:00
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
}
2026-03-06 12:30:05 +02:00
if !secureTokenMatches(existing.SessionToken, input.SessionToken) {
return JoinRoomResult{}, ErrUnauthorized
}
2026-03-05 22:08:06 +02:00
2026-03-05 22:41:16 +02:00
wasConnected := existing.Connected
2026-03-05 22:08:06 +02:00
existing.Username = username
existing.Connected = true
existing.UpdatedAt = now
if isAdminByToken {
existing.IsAdmin = true
}
2026-03-05 22:41:16 +02:00
if !wasConnected {
m.appendActivityLogLocked(room, "%s joined as %s.", existing.Username, existing.Role)
}
2026-03-05 22:08:06 +02:00
room.UpdatedAt = now
if err := m.store.Save(room); err != nil {
return JoinRoomResult{}, err
}
go m.broadcastRoom(room.ID)
return JoinRoomResult{
ParticipantID: existing.ID,
2026-03-06 12:30:05 +02:00
SessionToken: existing.SessionToken,
2026-03-05 22:08:06 +02:00
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{
2026-03-06 12:30:05 +02:00
ID: newUUIDv4(),
SessionToken: randomHex(24),
2026-03-05 22:08:06 +02:00
Username: username,
Role: role,
IsAdmin: isAdminByToken,
Connected: true,
HasVoted: false,
JoinedAt: now,
UpdatedAt: now,
}
room.Participants[participant.ID] = participant
2026-03-05 22:41:16 +02:00
m.appendActivityLogLocked(room, "%s joined as %s.", participant.Username, participant.Role)
2026-03-05 22:08:06 +02:00
room.UpdatedAt = now
if err := m.store.Save(room); err != nil {
return JoinRoomResult{}, err
}
go m.broadcastRoom(room.ID)
return JoinRoomResult{
ParticipantID: participant.ID,
2026-03-06 12:30:05 +02:00
SessionToken: participant.SessionToken,
2026-03-05 22:08:06 +02:00
IsAdmin: participant.IsAdmin,
Role: participant.Role,
Username: participant.Username,
}, nil
}
2026-03-06 12:30:05 +02:00
func (m *Manager) LeaveRoom(roomID, participantID, sessionToken string) error {
2026-03-05 22:08:06 +02:00
room, err := m.getRoom(roomID)
if err != nil {
return err
}
room.mu.Lock()
defer room.mu.Unlock()
2026-03-06 12:30:05 +02:00
participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken)
if err != nil {
return err
2026-03-05 22:08:06 +02:00
}
2026-03-05 22:41:16 +02:00
if !participant.Connected {
return nil
}
m.disconnectParticipantLocked(room, participant)
m.appendActivityLogLocked(room, "%s left the room.", participant.Username)
2026-03-05 22:08:06 +02:00
if err := m.store.Save(room); err != nil {
return err
}
go m.broadcastRoom(room.ID)
return nil
}
2026-03-06 12:30:05 +02:00
func (m *Manager) CastVote(roomID, participantID, sessionToken, card string) error {
2026-03-05 22:08:06 +02:00
room, err := m.getRoom(roomID)
if err != nil {
return err
}
room.mu.Lock()
defer room.mu.Unlock()
2026-03-06 12:30:05 +02:00
participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken)
if err != nil {
return err
2026-03-05 22:08:06 +02:00
}
if participant.Role != RoleParticipant {
return ErrUnauthorized
}
normalizedCard := normalizeCard(card)
if normalizedCard == "" || !slices.Contains(room.Settings.Cards, normalizedCard) {
return ErrInvalidCard
}
2026-03-06 12:30:05 +02:00
if participant.HasVoted {
if participant.VoteValue == normalizedCard {
return nil
}
if !room.Settings.AllowVoteChange {
return ErrVoteChangeLocked
2026-03-05 22:08:06 +02:00
}
}
2026-03-06 12:30:05 +02:00
previousVote := participant.VoteValue
hadVoted := participant.HasVoted
2026-03-05 22:08:06 +02:00
participant.HasVoted = true
participant.VoteValue = normalizedCard
participant.UpdatedAt = nowUTC()
room.UpdatedAt = nowUTC()
2026-03-06 12:30:05 +02:00
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)
}
2026-03-05 22:08:06 +02:00
if room.Settings.RevealMode == RevealModeAutoAll && allActiveParticipantsVoted(room) {
room.Round.Revealed = true
2026-03-05 22:41:16 +02:00
m.appendActivityLogLocked(room, "Votes auto-revealed after all active participants voted.")
2026-03-05 22:08:06 +02:00
}
if err := m.store.Save(room); err != nil {
return err
}
go m.broadcastRoom(room.ID)
return nil
}
2026-03-06 12:30:05 +02:00
func (m *Manager) RevealVotes(roomID, participantID, sessionToken string) error {
2026-03-05 22:08:06 +02:00
room, err := m.getRoom(roomID)
if err != nil {
return err
}
room.mu.Lock()
defer room.mu.Unlock()
2026-03-06 12:30:05 +02:00
participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken)
if err != nil {
return err
2026-03-05 22:08:06 +02:00
}
if !participant.IsAdmin {
return ErrUnauthorized
}
room.Round.Revealed = true
room.UpdatedAt = nowUTC()
2026-03-05 22:41:16 +02:00
m.appendActivityLogLocked(room, "%s revealed the votes.", participant.Username)
2026-03-05 22:08:06 +02:00
if err := m.store.Save(room); err != nil {
return err
}
go m.broadcastRoom(room.ID)
return nil
}
2026-03-06 12:30:05 +02:00
func (m *Manager) ResetVotes(roomID, participantID, sessionToken string) error {
2026-03-05 22:08:06 +02:00
room, err := m.getRoom(roomID)
if err != nil {
return err
}
room.mu.Lock()
defer room.mu.Unlock()
2026-03-06 12:30:05 +02:00
participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken)
if err != nil {
return err
2026-03-05 22:08:06 +02:00
}
if !participant.IsAdmin {
return ErrUnauthorized
}
m.resetVotesLocked(room)
room.UpdatedAt = nowUTC()
2026-03-05 22:41:16 +02:00
m.appendActivityLogLocked(room, "%s reset all votes.", participant.Username)
2026-03-05 22:08:06 +02:00
if err := m.store.Save(room); err != nil {
return err
}
go m.broadcastRoom(room.ID)
return nil
}
2026-03-06 12:30:05 +02:00
func (m *Manager) Subscribe(roomID, participantID, sessionToken string) (<-chan []byte, []byte, func(), error) {
2026-03-05 22:08:06 +02:00
room, err := m.getRoom(roomID)
if err != nil {
return nil, nil, nil, err
}
room.mu.Lock()
2026-03-06 12:30:05 +02:00
participant, authErr := m.authorizeParticipantLocked(room, participantID, sessionToken)
if authErr != nil {
2026-03-05 22:08:06 +02:00
room.mu.Unlock()
2026-03-06 12:30:05 +02:00
return nil, nil, nil, authErr
2026-03-05 22:08:06 +02:00
}
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 {
2026-03-05 22:41:16 +02:00
if p.Connected {
m.disconnectParticipantLocked(roomRef, p)
m.appendActivityLogLocked(roomRef, "%s disconnected.", p.Username)
_ = m.store.Save(roomRef)
}
2026-03-05 22:08:06 +02:00
}
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
}
2026-03-06 12:30:05 +02:00
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
}
2026-03-05 22:08:06 +02:00
func (m *Manager) loadFromDisk() error {
persistedRooms, err := m.store.LoadAll()
if err != nil {
return err
}
for _, persisted := range persistedRooms {
2026-03-06 12:30:05 +02:00
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,
}
2026-03-05 22:08:06 +02:00
room := &Room{
ID: persisted.ID,
AdminToken: persisted.AdminToken,
CreatedAt: persisted.CreatedAt,
UpdatedAt: persisted.UpdatedAt,
2026-03-06 12:30:05 +02:00
Settings: settings,
2026-03-05 22:08:06 +02:00
Round: persisted.Round,
Participants: make(map[string]*Participant, len(persisted.Participants)),
2026-03-05 22:41:16 +02:00
ActivityLog: append([]ActivityLogEntry(nil), persisted.ActivityLog...),
2026-03-05 22:08:06 +02:00
subscribers: map[string]*subscriber{},
}
for _, participant := range persisted.Participants {
2026-03-06 12:30:05 +02:00
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,
}
2026-03-05 22:08:06 +02:00
}
m.rooms[room.ID] = room
}
return nil
}
func (room *Room) toPersisted() persistedRoom {
2026-03-06 12:30:05 +02:00
allowVoteChange := room.Settings.AllowVoteChange
participants := make([]*persistedParticipant, 0, len(room.Participants))
2026-03-05 22:08:06 +02:00
for _, participant := range sortParticipants(room.Participants) {
2026-03-06 12:30:05 +02:00
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,
})
2026-03-05 22:08:06 +02:00
}
return persistedRoom{
ID: room.ID,
AdminToken: room.AdminToken,
CreatedAt: room.CreatedAt,
UpdatedAt: room.UpdatedAt,
2026-03-06 12:30:05 +02:00
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,
},
2026-03-05 22:08:06 +02:00
Round: room.Round,
Participants: participants,
2026-03-05 22:41:16 +02:00
ActivityLog: append([]ActivityLogEntry(nil), room.ActivityLog...),
2026-03-05 22:08:06 +02:00
}
}
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) {
2026-03-05 22:38:31 +02:00
if !participant.Connected {
continue
}
2026-03-05 22:08:06 +02:00
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,
2026-03-06 12:30:05 +02:00
AllowVoteChange: room.Settings.AllowVoteChange,
2026-03-05 22:08:06 +02:00
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
2026-03-05 22:41:16 +02:00
start := 0
2026-03-07 01:19:37 +02:00
if len(room.ActivityLog) > m.adminLogBroadcastLimit {
start = len(room.ActivityLog) - m.adminLogBroadcastLimit
2026-03-05 22:41:16 +02:00
}
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,
})
}
2026-03-05 22:08:06 +02:00
}
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:
}
}
}
2026-03-05 22:41:16 +02:00
func (m *Manager) appendActivityLogLocked(room *Room, format string, args ...any) {
room.ActivityLog = append(room.ActivityLog, ActivityLogEntry{
At: nowUTC(),
Message: fmt.Sprintf(format, args...),
})
2026-03-07 01:19:37 +02:00
if len(room.ActivityLog) > m.maxActivityLogEntries {
room.ActivityLog = append([]ActivityLogEntry(nil), room.ActivityLog[len(room.ActivityLog)-m.maxActivityLogEntries:]...)
2026-03-05 22:41:16 +02:00
}
}
func (m *Manager) disconnectParticipantLocked(room *Room, participant *Participant) {
participant.Connected = false
participant.UpdatedAt = nowUTC()
room.UpdatedAt = nowUTC()
}
2026-03-07 01:16:39 +02:00
func hasConnectedParticipantsLocked(room *Room) bool {
for _, participant := range room.Participants {
if participant.Connected {
return true
}
}
return false
}