2026-03-05 22:08:06 +02:00
|
|
|
package state
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
2026-03-05 22:41:16 +02:00
|
|
|
"fmt"
|
2026-03-05 22:08:06 +02:00
|
|
|
"slices"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-05 22:41:16 +02:00
|
|
|
const (
|
|
|
|
|
maxActivityLogEntries = 400
|
|
|
|
|
adminLogBroadcastLimit = 200
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-05 22:08:06 +02:00
|
|
|
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,
|
|
|
|
|
},
|
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,
|
|
|
|
|
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-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,
|
|
|
|
|
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
|
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,
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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()
|
2026-03-05 22:41:16 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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()
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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()
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)),
|
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 {
|
|
|
|
|
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,
|
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,
|
|
|
|
|
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
|
|
|
|
|
if len(room.ActivityLog) > adminLogBroadcastLimit {
|
|
|
|
|
start = len(room.ActivityLog) - 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,
|
|
|
|
|
})
|
|
|
|
|
}
|
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...),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if len(room.ActivityLog) > maxActivityLogEntries {
|
|
|
|
|
room.ActivityLog = append([]ActivityLogEntry(nil), room.ActivityLog[len(room.ActivityLog)-maxActivityLogEntries:]...)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *Manager) disconnectParticipantLocked(room *Room, participant *Participant) {
|
|
|
|
|
participant.Connected = false
|
|
|
|
|
participant.UpdatedAt = nowUTC()
|
|
|
|
|
room.UpdatedAt = nowUTC()
|
|
|
|
|
}
|