where_is_waldo 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +467 -0
  5. data/VERSION +1 -0
  6. data/app/channels/where_is_waldo/application_cable/channel.rb +19 -0
  7. data/app/channels/where_is_waldo/application_cable/connection.rb +45 -0
  8. data/app/channels/where_is_waldo/jwt_connection.rb +83 -0
  9. data/app/channels/where_is_waldo/presence_channel.rb +86 -0
  10. data/app/channels/where_is_waldo/roster_channel.rb +52 -0
  11. data/app/jobs/where_is_waldo/application_job.rb +6 -0
  12. data/app/jobs/where_is_waldo/presence_cleanup_job.rb +13 -0
  13. data/app/models/concerns/where_is_waldo/broadcastable.rb +85 -0
  14. data/app/models/where_is_waldo/application_record.rb +7 -0
  15. data/app/models/where_is_waldo/presence.rb +87 -0
  16. data/app/services/where_is_waldo/adapters/base_adapter.rb +80 -0
  17. data/app/services/where_is_waldo/adapters/database_adapter.rb +104 -0
  18. data/app/services/where_is_waldo/adapters/redis_adapter.rb +205 -0
  19. data/app/services/where_is_waldo/broadcaster.rb +79 -0
  20. data/app/services/where_is_waldo/presence_service.rb +119 -0
  21. data/app/services/where_is_waldo/roster.rb +246 -0
  22. data/app/services/where_is_waldo/roster_delivery.rb +184 -0
  23. data/lib/generators/where_is_waldo/install/install_generator.rb +103 -0
  24. data/lib/generators/where_is_waldo/install/templates/channel.rb.tt +6 -0
  25. data/lib/generators/where_is_waldo/install/templates/connection.rb.tt +51 -0
  26. data/lib/generators/where_is_waldo/install/templates/create_presences.rb.tt +39 -0
  27. data/lib/generators/where_is_waldo/install/templates/initializer.rb.tt +37 -0
  28. data/lib/generators/where_is_waldo/install/templates/migration.rb.tt +20 -0
  29. data/lib/generators/where_is_waldo/install/templates/presence_channel.rb.tt +37 -0
  30. data/lib/where_is_waldo/configuration.rb +209 -0
  31. data/lib/where_is_waldo/engine.rb +17 -0
  32. data/lib/where_is_waldo/version.rb +5 -0
  33. data/lib/where_is_waldo.rb +117 -0
  34. metadata +249 -0
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhereIsWaldo
4
+ module Adapters
5
+ class RedisAdapter < BaseAdapter
6
+ def connect(session_id:, subject_id:, metadata: {})
7
+ now = Time.current.to_i
8
+
9
+ presence_data = {
10
+ session_id: session_id,
11
+ subject_id: subject_id,
12
+ connected_at: now,
13
+ last_heartbeat: now,
14
+ tab_visible: true,
15
+ subject_active: true,
16
+ last_activity: now,
17
+ metadata: metadata
18
+ }
19
+
20
+ redis.multi do |tx|
21
+ # Store session data
22
+ tx.set(session_key(session_id), presence_data.to_json, ex: ttl)
23
+
24
+ # Add to online subjects sorted set with timestamp as score
25
+ tx.zadd(online_subjects_key, now, subject_id)
26
+
27
+ # Add to subject's sessions set
28
+ tx.sadd(subject_sessions_key(subject_id), session_id)
29
+
30
+ # Map session to subject for reverse lookup
31
+ tx.set(session_subject_key(session_id), subject_id, ex: ttl)
32
+ end
33
+
34
+ true
35
+ rescue StandardError => e
36
+ Rails.logger.error "[WhereIsWaldo] Redis connect failed: #{e.message}"
37
+ false
38
+ end
39
+
40
+ def disconnect(session_id: nil, subject_id: nil)
41
+ if session_id
42
+ disconnect_session(session_id)
43
+ elsif subject_id
44
+ disconnect_subject(subject_id)
45
+ end
46
+
47
+ true
48
+ rescue StandardError => e
49
+ Rails.logger.error "[WhereIsWaldo] Redis disconnect failed: #{e.message}"
50
+ false
51
+ end
52
+
53
+ def heartbeat(session_id:, tab_visible: true, subject_active: true, last_activity_at: nil, metadata: {})
54
+ data = get_presence_data(session_id)
55
+ return false unless data
56
+
57
+ now = Time.current.to_i
58
+ data["last_heartbeat"] = now
59
+ data["tab_visible"] = tab_visible
60
+ data["subject_active"] = subject_active
61
+ # Update last_activity: use JS timestamp if provided, otherwise use current time when active
62
+ if last_activity_at
63
+ data["last_activity"] = (last_activity_at / 1000.0).to_i
64
+ elsif subject_active
65
+ data["last_activity"] = now
66
+ end
67
+ data["metadata"] = data["metadata"].merge(metadata) if metadata.present?
68
+
69
+ redis.multi do |tx|
70
+ tx.set(session_key(session_id), data.to_json, ex: ttl)
71
+ tx.zadd(online_subjects_key, now, data["subject_id"])
72
+ end
73
+
74
+ true
75
+ rescue StandardError => e
76
+ Rails.logger.error "[WhereIsWaldo] Redis heartbeat failed: #{e.message}"
77
+ false
78
+ end
79
+
80
+ def online_subject_ids(timeout: nil)
81
+ threshold = Time.current.to_i - (timeout || default_timeout)
82
+
83
+ # Get subject IDs with heartbeat after threshold
84
+ redis.zrangebyscore(online_subjects_key, threshold, "+inf").map(&:to_i)
85
+ end
86
+
87
+ def sessions_for_subject(subject_id)
88
+ session_ids = redis.smembers(subject_sessions_key(subject_id))
89
+
90
+ session_ids.filter_map do |sid|
91
+ data = get_presence_data(sid)
92
+ build_presence_hash(data) if data
93
+ end
94
+ end
95
+
96
+ def session_status(session_id)
97
+ data = get_presence_data(session_id)
98
+ return nil unless data
99
+
100
+ build_presence_hash(data)
101
+ end
102
+
103
+ def cleanup(timeout: nil)
104
+ threshold = Time.current.to_i - (timeout || default_timeout)
105
+ cleaned = 0
106
+
107
+ # Get stale subject IDs
108
+ stale_subject_ids = redis.zrangebyscore(online_subjects_key, "-inf", threshold)
109
+
110
+ stale_subject_ids.each do |subject_id|
111
+ # Check if any sessions are still active
112
+ session_ids = redis.smembers(subject_sessions_key(subject_id))
113
+ all_stale = session_ids.all? do |sid|
114
+ data = get_presence_data(sid)
115
+ !data || data["last_heartbeat"].to_i < threshold
116
+ end
117
+
118
+ if all_stale
119
+ disconnect_subject(subject_id)
120
+ cleaned += 1
121
+ end
122
+ end
123
+
124
+ # Remove stale entries from sorted set
125
+ redis.zremrangebyscore(online_subjects_key, "-inf", threshold)
126
+
127
+ cleaned
128
+ end
129
+
130
+ private
131
+
132
+ def redis
133
+ config.redis_client || raise("Redis client not configured")
134
+ end
135
+
136
+ def ttl
137
+ (config.timeout * 1.5).to_i # Give some buffer beyond timeout
138
+ end
139
+
140
+ def key_prefix
141
+ config.redis_prefix || "where_is_waldo"
142
+ end
143
+
144
+ def session_key(session_id)
145
+ "#{key_prefix}:session:#{session_id}"
146
+ end
147
+
148
+ def online_subjects_key
149
+ "#{key_prefix}:online_subjects"
150
+ end
151
+
152
+ def subject_sessions_key(subject_id)
153
+ "#{key_prefix}:subject:#{subject_id}:sessions"
154
+ end
155
+
156
+ def session_subject_key(session_id)
157
+ "#{key_prefix}:session_subject:#{session_id}"
158
+ end
159
+
160
+ def get_presence_data(session_id)
161
+ json = redis.get(session_key(session_id))
162
+ return nil unless json
163
+
164
+ JSON.parse(json)
165
+ end
166
+
167
+ def disconnect_session(session_id)
168
+ data = get_presence_data(session_id)
169
+ return unless data
170
+
171
+ subject_id = data["subject_id"]
172
+
173
+ redis.multi do |tx|
174
+ tx.del(session_key(session_id))
175
+ tx.srem(subject_sessions_key(subject_id), session_id)
176
+ tx.del(session_subject_key(session_id))
177
+ end
178
+
179
+ # If no more sessions for this subject, remove from online set
180
+ remaining = redis.scard(subject_sessions_key(subject_id))
181
+ redis.zrem(online_subjects_key, subject_id) if remaining.zero?
182
+ end
183
+
184
+ def disconnect_subject(subject_id)
185
+ session_ids = redis.smembers(subject_sessions_key(subject_id))
186
+ session_ids.each { |sid| disconnect_session(sid) }
187
+ redis.zrem(online_subjects_key, subject_id)
188
+ end
189
+
190
+ def build_presence_hash(data)
191
+ {
192
+ session_id: data["session_id"],
193
+ subject_id: data["subject_id"],
194
+ connected_at: Time.zone.at(data["connected_at"]),
195
+ last_heartbeat: Time.zone.at(data["last_heartbeat"]),
196
+ last_activity: data["last_activity"] ? Time.zone.at(data["last_activity"]) : nil,
197
+ tab_visible: data["tab_visible"],
198
+ subject_active: data["subject_active"],
199
+ metadata: data["metadata"] || {},
200
+ subject: nil # Redis adapter doesn't eager load subjects
201
+ }
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhereIsWaldo
4
+ class Broadcaster
5
+ class << self
6
+ # Broadcast a message to subjects in an AR scope
7
+ # @param scope [ActiveRecord::Relation, ActiveRecord::Base] Subjects to broadcast to
8
+ # @param message_type [String, Symbol] Message type for client handler routing
9
+ # @param data [Hash] Message payload
10
+ def broadcast_to(scope, message_type, data = {})
11
+ subject_ids = extract_subject_ids(scope)
12
+ return if subject_ids.empty?
13
+
14
+ message = build_message(message_type, data)
15
+
16
+ subject_ids.each do |subject_id|
17
+ ActionCable.server.broadcast(subject_stream(subject_id), message)
18
+ end
19
+ end
20
+
21
+ # Broadcast to online subjects only (from an AR scope)
22
+ # @param scope [ActiveRecord::Relation] Scope to filter
23
+ # @param message_type [String, Symbol] Message type
24
+ # @param data [Hash] Message payload
25
+ def broadcast_to_online(scope, message_type, data = {})
26
+ online_ids = PresenceService.online_ids(scope)
27
+ return if online_ids.empty?
28
+
29
+ message = build_message(message_type, data)
30
+
31
+ online_ids.each do |subject_id|
32
+ ActionCable.server.broadcast(subject_stream(subject_id), message)
33
+ end
34
+ end
35
+
36
+ # Broadcast to a specific session
37
+ # @param session_id [String] Session identifier
38
+ # @param message_type [String, Symbol] Message type
39
+ # @param data [Hash] Message payload
40
+ def broadcast_to_session(session_id, message_type, data = {})
41
+ status = PresenceService.session_status(session_id)
42
+ return false unless status
43
+
44
+ message = build_message(message_type, data, target_session: session_id)
45
+ ActionCable.server.broadcast(subject_stream(status[:subject_id]), message)
46
+ true
47
+ end
48
+
49
+ private
50
+
51
+ def extract_subject_ids(scope)
52
+ case scope
53
+ when ActiveRecord::Relation
54
+ scope.pluck(:id)
55
+ when ActiveRecord::Base
56
+ [scope.id]
57
+ when Array
58
+ scope.map { |s| s.respond_to?(:id) ? s.id : s }
59
+ else
60
+ Array(scope)
61
+ end
62
+ end
63
+
64
+ def build_message(message_type, data, target_session: nil)
65
+ msg = {
66
+ type: message_type.to_s,
67
+ data: data,
68
+ timestamp: Time.current.iso8601
69
+ }
70
+ msg[:_target_session] = target_session if target_session
71
+ msg
72
+ end
73
+
74
+ def subject_stream(subject_id)
75
+ "where_is_waldo:subject:#{subject_id}"
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhereIsWaldo
4
+ class PresenceService
5
+ class << self
6
+ # Register a new presence
7
+ # @param session_id [String] Unique session identifier
8
+ # @param subject_id [Integer/String] Subject identifier (user, member, etc.)
9
+ # @param metadata [Hash] Additional data
10
+ # @return [Boolean] success
11
+ def connect(session_id:, subject_id:, metadata: {})
12
+ adapter.connect(
13
+ session_id: session_id,
14
+ subject_id: subject_id,
15
+ metadata: metadata
16
+ )
17
+ end
18
+
19
+ # Remove a presence by session, or all presences for a subject
20
+ # @param session_id [String] Session identifier (optional)
21
+ # @param subject_id [Integer/String] Subject identifier (optional)
22
+ # @return [Boolean] success
23
+ def disconnect(session_id: nil, subject_id: nil)
24
+ adapter.disconnect(
25
+ session_id: session_id,
26
+ subject_id: subject_id
27
+ )
28
+ end
29
+
30
+ # Update heartbeat for a session
31
+ # @param session_id [String] Session identifier
32
+ # @param tab_visible [Boolean] Is tab in foreground
33
+ # @param subject_active [Boolean] Recent activity
34
+ # @param last_activity_at [Integer] Unix timestamp (ms) of last user activity
35
+ # @param metadata [Hash] Additional data to merge
36
+ # @return [Boolean] success
37
+ def heartbeat(session_id:, tab_visible: true, subject_active: true, last_activity_at: nil, metadata: {})
38
+ adapter.heartbeat(
39
+ session_id: session_id,
40
+ tab_visible: tab_visible,
41
+ subject_active: subject_active,
42
+ last_activity_at: last_activity_at,
43
+ metadata: metadata
44
+ )
45
+ end
46
+
47
+ # Get online subjects from an AR scope
48
+ # @param scope [ActiveRecord::Relation] Scope to filter
49
+ # @param timeout [Integer] Seconds threshold (defaults to config.timeout)
50
+ # @return [ActiveRecord::Relation] Online subjects from the scope
51
+ def online(scope, timeout: nil)
52
+ online_ids = adapter.online_subject_ids(timeout: timeout)
53
+ scope.where(id: online_ids)
54
+ end
55
+
56
+ # Get online subject IDs from an AR scope
57
+ # @param scope [ActiveRecord::Relation] Scope to filter
58
+ # @param timeout [Integer] Seconds threshold (defaults to config.timeout)
59
+ # @return [Array<Integer>] Online subject IDs
60
+ def online_ids(scope, timeout: nil)
61
+ online_ids = adapter.online_subject_ids(timeout: timeout)
62
+ scope.where(id: online_ids).pluck(:id)
63
+ end
64
+
65
+ # Get all online subject IDs (no scope filtering)
66
+ # @param timeout [Integer] Seconds threshold
67
+ # @return [Array<Integer>]
68
+ def all_online_ids(timeout: nil)
69
+ adapter.online_subject_ids(timeout: timeout)
70
+ end
71
+
72
+ # Get all sessions for a subject
73
+ # @param subject_id [Integer/String] Subject identifier
74
+ # @return [Array<Hash>] Presence records
75
+ delegate :sessions_for_subject, to: :adapter
76
+
77
+ # Get status of a specific session
78
+ # @param session_id [String] Session identifier
79
+ # @return [Hash, nil] Presence record or nil
80
+ delegate :session_status, to: :adapter
81
+
82
+ # Check if a subject is online
83
+ # @param subject_id [Integer/String] Subject identifier
84
+ # @return [Boolean]
85
+ def subject_online?(subject_id)
86
+ sessions_for_subject(subject_id).any?
87
+ end
88
+
89
+ # Cleanup stale records
90
+ # @param timeout [Integer] Seconds threshold (defaults to config.timeout)
91
+ # @return [Integer] Number of records removed
92
+ def cleanup(timeout: nil)
93
+ adapter.cleanup(timeout: timeout)
94
+ end
95
+
96
+ private
97
+
98
+ def adapter
99
+ @adapter ||= build_adapter
100
+ end
101
+
102
+ def build_adapter
103
+ case WhereIsWaldo.config.adapter
104
+ when :database
105
+ Adapters::DatabaseAdapter.new
106
+ when :redis
107
+ Adapters::RedisAdapter.new
108
+ else
109
+ raise ArgumentError, "Unknown adapter: #{WhereIsWaldo.config.adapter}"
110
+ end
111
+ end
112
+
113
+ # Reset adapter (useful for testing or config changes)
114
+ def reset_adapter!
115
+ @adapter = nil
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhereIsWaldo
4
+ # Live presence roster for an org/account: "who's around, and how present are
5
+ # they right now." Built for efficiency — a single shared ActionCable stream
6
+ # per org, an initial snapshot on subscribe, and compact deltas broadcast only
7
+ # on state transitions (never on steady-state heartbeats).
8
+ #
9
+ # A member's presence is reported per device AND as an overall roll-up:
10
+ #
11
+ # { id: 7, status: "active", devices: { "web" => "active", "mobile" => "idle" } }
12
+ #
13
+ # `devices[platform]` is that platform's own aggregate status (several browser
14
+ # tabs roll up into one "web" status); `status` is the highest level across
15
+ # all platforms — the "is the user active anywhere?" answer, while
16
+ # `devices["mobile"]` answers "is the user active on mobile?". Each status is:
17
+ #
18
+ # active - a live session on that device is tab-visible AND working
19
+ # idle - a live session is tab-visible but not actively using
20
+ # background - only backgrounded/hidden sessions are live (tab hidden or
21
+ # mobile app backgrounded)
22
+ # offline - no live sessions (overall status only; absent from devices)
23
+ #
24
+ # Enable by configuring `roster_org` (and optionally `roster_members`).
25
+ class Roster
26
+ # Activity ranking used to pick a subject's aggregate status from the "most
27
+ # present" of their sessions.
28
+ RANK = { active: 3, idle: 2, background: 1, offline: 0 }.freeze
29
+ DEFAULT_PLATFORM = "web"
30
+
31
+ class << self
32
+ # Shared stream name for an org record (nil when org is absent).
33
+ def stream_name(org)
34
+ return nil unless org
35
+
36
+ klass = org.class.respond_to?(:base_class) ? org.class.base_class : org.class
37
+ "where_is_waldo:roster:#{klass.name}:#{org.id}"
38
+ end
39
+
40
+ # Shared stream name for the org a subject belongs to (nil when the roster
41
+ # is unconfigured or the subject has no org).
42
+ def stream_for_subject(subject)
43
+ stream_name(WhereIsWaldo.config.resolve_roster_org(subject))
44
+ end
45
+
46
+ # Full roster state for an org: every member subject with their current
47
+ # aggregate presence. Sent once, on subscribe.
48
+ # @return [Array<Hash>] member state hashes
49
+ def snapshot(org, timeout: nil)
50
+ members = WhereIsWaldo.config.resolve_members(org)
51
+ return [] unless members
52
+
53
+ members_for(members, timeout: timeout).values
54
+ end
55
+
56
+ # Member states for an arbitrary AR scope of subjects, keyed by id. Used
57
+ # by :poll to snapshot / diff a viewer's visible scope.
58
+ # @return [Hash{Object => Hash}] id => member hash
59
+ def members_for(scope, timeout: nil)
60
+ return {} unless scope
61
+
62
+ records = scope.to_a
63
+ states = states_for(records.map(&:id), timeout: timeout)
64
+ records.to_h { |record| [record.id, build_member(record, states[record.id])] }
65
+ end
66
+
67
+ # === Cable message builders — identical shapes across every delivery mode
68
+ # so the client reducer never forks ===
69
+
70
+ def snapshot_message(members, mode:)
71
+ message = { type: "roster_snapshot", mode: mode.to_s, members: members }
72
+ message[:poll_interval] = WhereIsWaldo.config.roster_poll_interval if %i[poll nudge].include?(mode.to_sym)
73
+ message[:nudge_jitter] = WhereIsWaldo.config.roster_nudge_jitter if mode.to_sym == :nudge
74
+ message
75
+ end
76
+
77
+ def delta_message(member)
78
+ { type: "roster_delta", member: member }
79
+ end
80
+
81
+ # Content-free "roster changed, re-poll" trigger (:nudge mode). Carries no
82
+ # identity/state, so it leaks nothing beyond "activity happened".
83
+ def nudge_message
84
+ { type: "roster_nudge" }
85
+ end
86
+
87
+ # Tells the client to drop a member who left the viewer's visible scope
88
+ # (distinct from going offline, which stays present with status "offline").
89
+ def removed_message(subject_id)
90
+ { type: "roster_delta", member: { id: subject_id, _removed: true } }
91
+ end
92
+
93
+ # Aggregate presence state for a single subject across all their sessions.
94
+ # @return [Hash] { status:, devices: { platform => status } }
95
+ def state_for(subject_id, timeout: nil)
96
+ states_for([subject_id], timeout: timeout).fetch(subject_id, offline_state)
97
+ end
98
+
99
+ # Presence status for a subject on a specific device/platform, e.g.
100
+ # WhereIsWaldo::Roster.device_status(user.id, :mobile) # => "idle"
101
+ # Answers "is the user active on mobile?" vs the overall state_for.
102
+ # @return [String] "active" | "idle" | "background" | "offline"
103
+ def device_status(subject_id, platform, timeout: nil)
104
+ state_for(subject_id, timeout: timeout)[:devices][platform.to_s] || "offline"
105
+ end
106
+
107
+ # Broadcast a compact delta for one subject to their org roster stream.
108
+ # Intended to be called ONLY on transitions (connect/disconnect/active
109
+ # flip). No-ops safely when the roster is unconfigured or the subject has
110
+ # no org. Recomputes the subject's aggregate so multi-session state (e.g.
111
+ # a second tab still active) is always correct.
112
+ def publish(subject_id, timeout: nil)
113
+ return false if subject_id.blank?
114
+
115
+ subject = find_subject(subject_id)
116
+ stream = stream_name(WhereIsWaldo.config.resolve_roster_org(subject))
117
+ return false unless stream
118
+
119
+ member = build_member_by_id(subject, subject_id, timeout: timeout)
120
+ ActionCable.server.broadcast(stream, delta_message(member))
121
+ true
122
+ rescue StandardError => e
123
+ Rails.logger&.warn("[WhereIsWaldo::Roster] publish failed for #{subject_id}: #{e.class}: #{e.message}")
124
+ false
125
+ end
126
+
127
+ # Per-viewer stream (:fanout mode). Each viewer streams only their own.
128
+ def viewer_stream(viewer_id)
129
+ "where_is_waldo:roster:viewer:#{viewer_id}"
130
+ end
131
+
132
+ # Push a subject's delta to every viewer allowed to see them (:fanout
133
+ # mode). Directional audience (config.roster_viewers_of) makes this correct
134
+ # for arbitrary/asymmetric visibility, at O(audience) broadcasts.
135
+ def publish_fanout(subject_id, timeout: nil)
136
+ return false if subject_id.blank?
137
+
138
+ subject = find_subject(subject_id)
139
+ audience = subject && WhereIsWaldo.config.resolve_viewers_of(subject)
140
+ return false unless audience
141
+
142
+ message = delta_message(build_member_by_id(subject, subject_id, timeout: timeout))
143
+ audience.pluck(:id).each { |viewer_id| ActionCable.server.broadcast(viewer_stream(viewer_id), message) }
144
+ true
145
+ rescue StandardError => e
146
+ Rails.logger&.warn("[WhereIsWaldo::Roster] fanout failed for #{subject_id}: #{e.class}: #{e.message}")
147
+ false
148
+ end
149
+
150
+ # Broadcast a content-free nudge to a subject's org roster stream (:nudge
151
+ # mode). Tells watchers to re-poll; carries no protected data.
152
+ def publish_nudge(subject_id)
153
+ return false if subject_id.blank?
154
+
155
+ subject = find_subject(subject_id)
156
+ stream = stream_name(WhereIsWaldo.config.resolve_roster_org(subject))
157
+ return false unless stream
158
+
159
+ ActionCable.server.broadcast(stream, nudge_message)
160
+ true
161
+ rescue StandardError => e
162
+ Rails.logger&.warn("[WhereIsWaldo::Roster] nudge failed for #{subject_id}: #{e.class}: #{e.message}")
163
+ false
164
+ end
165
+
166
+ private
167
+
168
+ # Aggregate state for many subjects in one query, keyed by subject id.
169
+ def states_for(subject_ids, timeout: nil)
170
+ ids = Array(subject_ids).compact.uniq
171
+ return {} if ids.empty?
172
+
173
+ threshold = (timeout || WhereIsWaldo.config.timeout).seconds.ago
174
+ subject_col = Presence.subject_column
175
+
176
+ rows = Presence.where(subject_col => ids)
177
+ .where("last_heartbeat > ?", threshold)
178
+ .to_a
179
+
180
+ rows.group_by { |row| row[subject_col] }
181
+ .transform_values { |sessions| aggregate(sessions) }
182
+ end
183
+
184
+ # Reduce a subject's live sessions to per-device statuses plus an overall
185
+ # roll-up. Sessions are grouped by platform (so several browser tabs form
186
+ # one "web" status); the overall status is the highest across platforms.
187
+ # @return [Hash] { status: "active", devices: { "web" => "active", ... } }
188
+ def aggregate(sessions)
189
+ devices = sessions.group_by { |s| platform(s) }
190
+ .transform_values { |sess| platform_level(sess) }
191
+ overall = devices.values.max_by { |level| RANK[level] } || :offline
192
+ {
193
+ status: overall.to_s,
194
+ devices: devices.transform_values(&:to_s)
195
+ }
196
+ end
197
+
198
+ # Aggregate activity level for the sessions on one platform.
199
+ def platform_level(sessions)
200
+ sessions.map { |s| session_level(s) }.max_by { |level| RANK[level] } || :offline
201
+ end
202
+
203
+ # Activity level of one live session. Uniform across web and mobile:
204
+ # a hidden tab / backgrounded app is :background; a visible/foreground
205
+ # session is :active when working, else :idle.
206
+ def session_level(session)
207
+ return :background unless session.tab_visible
208
+
209
+ session.subject_active ? :active : :idle
210
+ end
211
+
212
+ def platform(session)
213
+ meta = session.metadata
214
+ value = meta && (meta["platform"] || meta[:platform])
215
+ (value.presence || DEFAULT_PLATFORM).to_s
216
+ end
217
+
218
+ # Build a member hash from an already-loaded state map entry.
219
+ def build_member(record, state)
220
+ merge_member(record, state || offline_state)
221
+ end
222
+
223
+ # Build a member hash by recomputing the subject's aggregate state.
224
+ def build_member_by_id(record, subject_id, timeout: nil)
225
+ merge_member(record, state_for(subject_id, timeout: timeout), id: subject_id)
226
+ end
227
+
228
+ def merge_member(record, state, id: nil)
229
+ data = record ? WhereIsWaldo.config.build_subject_data(record) : {}
230
+ data.merge(
231
+ id: id || record&.id,
232
+ status: state[:status],
233
+ devices: state[:devices]
234
+ )
235
+ end
236
+
237
+ def offline_state
238
+ { status: "offline", devices: {} }
239
+ end
240
+
241
+ def find_subject(subject_id)
242
+ WhereIsWaldo.config.subject_class_constant&.find_by(id: subject_id)
243
+ end
244
+ end
245
+ end
246
+ end