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,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhereIsWaldo
4
+ # Pluggable roster delivery strategies. Each app picks a mode per account (via
5
+ # config.roster_mode); the channels dispatch to the matching strategy. The
6
+ # data model (aggregation) and the wire message shapes are shared across all
7
+ # modes — only the *transport/trigger* differs, so the client reducer never
8
+ # forks.
9
+ #
10
+ # Strategies are pure/stateless (any state lives in Rails.cache): they COMPUTE
11
+ # streams + messages and perform cache/broadcast side effects, but the channel
12
+ # owns the actual ActionCable `transmit`/`stream_from`.
13
+ module RosterDelivery
14
+ module_function
15
+
16
+ # Resolve a strategy instance for a mode symbol.
17
+ def for(mode)
18
+ case mode.to_sym
19
+ when :broadcast then Broadcast.new
20
+ when :poll then Poll.new
21
+ when :nudge then Nudge.new
22
+ when :fanout then Fanout.new
23
+ else
24
+ raise ArgumentError, "Unknown roster_mode: #{mode.inspect}"
25
+ end
26
+ end
27
+
28
+ # Base contract. Channels call these; return values drive the channel's I/O.
29
+ class Base
30
+ # @return [Hash] { streams: [names], messages: [cable messages] }
31
+ def subscribe_plan(_subject, _session_id)
32
+ { streams: [], messages: [] }
33
+ end
34
+
35
+ # @return [Array<Hash>] cable messages to transmit to the polling client
36
+ def poll_messages(_subject, _session_id)
37
+ []
38
+ end
39
+
40
+ # Side effect on a presence transition (usually a broadcast). No return.
41
+ def on_transition(_subject_id); end
42
+
43
+ protected
44
+
45
+ def config
46
+ WhereIsWaldo.config
47
+ end
48
+ end
49
+
50
+ # Instant push to a single shared per-account stream. NO visibility
51
+ # filtering — everyone in the account sees everyone. O(1) per transition.
52
+ class Broadcast < Base
53
+ def subscribe_plan(subject, _session_id)
54
+ org = config.resolve_roster_org(subject)
55
+ stream = Roster.stream_name(org)
56
+ return { streams: [], messages: [] } unless stream
57
+
58
+ {
59
+ streams: [stream],
60
+ messages: [Roster.snapshot_message(Roster.snapshot(org), mode: :broadcast)]
61
+ }
62
+ end
63
+
64
+ def on_transition(subject_id)
65
+ Roster.publish(subject_id)
66
+ end
67
+ end
68
+
69
+ # Heartbeat/poll pull: the client polls; the server transmits a per-viewer,
70
+ # server-filtered snapshot (first poll / resync) or diff (subsequent). The
71
+ # diff baseline lives in Rails.cache keyed per session (tab), TTL'd so a
72
+ # stale/expired cursor self-heals into a full snapshot. Handles ANY
73
+ # visibility rule via config.roster_visible_to. No broadcasts.
74
+ class Poll < Base
75
+ # Overridden by :nudge so the snapshot advertises the right mode.
76
+ def mode
77
+ :poll
78
+ end
79
+
80
+ def subscribe_plan(subject, session_id)
81
+ current = current_members(subject)
82
+ write_baseline(session_id, current)
83
+ { streams: subscribe_streams(subject), messages: [Roster.snapshot_message(current.values, mode: mode)] }
84
+ end
85
+
86
+ def poll_messages(subject, session_id)
87
+ current = current_members(subject)
88
+ baseline = read_baseline(session_id)
89
+ write_baseline(session_id, current)
90
+
91
+ # Cache miss / expired cursor -> full snapshot (self-healing resync).
92
+ return [Roster.snapshot_message(current.values, mode: mode)] if baseline.nil?
93
+
94
+ diff_messages(baseline, current)
95
+ end
96
+
97
+ # No push work on transition — pull clients poll for changes.
98
+ def on_transition(_subject_id); end
99
+
100
+ private
101
+
102
+ # Streams to subscribe on connect. :poll needs none (data rides the poll
103
+ # transmit); :nudge overrides this to also receive the account nudge.
104
+ def subscribe_streams(_subject)
105
+ []
106
+ end
107
+
108
+ def current_members(subject)
109
+ Roster.members_for(config.resolve_visible_to(subject))
110
+ end
111
+
112
+ def diff_messages(baseline, current)
113
+ messages = []
114
+ current.each do |id, member|
115
+ prev = baseline[id]
116
+ messages << Roster.delta_message(member) if prev.nil? || presence_changed?(prev, member)
117
+ end
118
+ # Members that left the viewer's visible scope entirely (a visibility
119
+ # change) — tell the client to drop them.
120
+ (baseline.keys - current.keys).each do |gone_id|
121
+ messages << Roster.removed_message(gone_id)
122
+ end
123
+ messages
124
+ end
125
+
126
+ def presence_changed?(prev, member)
127
+ prev[:status] != member[:status] || prev[:devices] != member[:devices]
128
+ end
129
+
130
+ def read_baseline(session_id)
131
+ Rails.cache.read(baseline_key(session_id))
132
+ end
133
+
134
+ def write_baseline(session_id, members)
135
+ Rails.cache.write(baseline_key(session_id), members, expires_in: config.roster_cache_ttl)
136
+ end
137
+
138
+ def baseline_key(session_id)
139
+ "where_is_waldo:roster:baseline:#{session_id}"
140
+ end
141
+ end
142
+
143
+ # Poll, plus a content-free nudge broadcast on transition so clients re-poll
144
+ # immediately instead of waiting for the next interval — near-instant
145
+ # latency with the same server-filtered (arbitrary-visibility) delivery. The
146
+ # nudge carries no identity/state, so it only reveals "activity happened".
147
+ class Nudge < Poll
148
+ def mode
149
+ :nudge
150
+ end
151
+
152
+ def on_transition(subject_id)
153
+ Roster.publish_nudge(subject_id)
154
+ end
155
+
156
+ private
157
+
158
+ # Also subscribe the account stream so this client receives nudges.
159
+ def subscribe_streams(subject)
160
+ stream = Roster.stream_name(config.resolve_roster_org(subject))
161
+ stream ? [stream] : []
162
+ end
163
+ end
164
+
165
+ # Instant per-viewer push for arbitrary/asymmetric visibility. Each viewer
166
+ # streams only their own stream and gets a server-filtered snapshot on
167
+ # connect; on a transition the subject's delta is pushed to every viewer in
168
+ # its directional audience (config.roster_viewers_of). O(audience) per
169
+ # transition — the price of instant delivery under restricted visibility.
170
+ class Fanout < Base
171
+ def subscribe_plan(subject, _session_id)
172
+ members = Roster.members_for(config.resolve_visible_to(subject))
173
+ {
174
+ streams: [Roster.viewer_stream(subject.id)],
175
+ messages: [Roster.snapshot_message(members.values, mode: :fanout)]
176
+ }
177
+ end
178
+
179
+ def on_transition(subject_id)
180
+ Roster.publish_fanout(subject_id)
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module WhereIsWaldo
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ class_option :adapter, type: :string, default: "database",
14
+ desc: "Storage adapter (database or redis)"
15
+ class_option :auth_method, type: :string, default: "jwt",
16
+ desc: "Authentication method (jwt, devise, custom)"
17
+ class_option :subject_class, type: :string, default: "User",
18
+ desc: "The model class being tracked (User, Member, etc.)"
19
+ class_option :subject_column, type: :string, default: "user_id",
20
+ desc: "Foreign key column name for the subject"
21
+ class_option :session_column, type: :string, default: "jti",
22
+ desc: "Column name for session identifier"
23
+
24
+ def create_initializer
25
+ template "initializer.rb.tt", "config/initializers/where_is_waldo.rb"
26
+ end
27
+
28
+ def generate_migration
29
+ return if adapter == "redis"
30
+
31
+ migration_template "migration.rb.tt", "db/migrate/create_presences.rb"
32
+ end
33
+
34
+ def create_channels
35
+ template "connection.rb.tt", "app/channels/application_cable/connection.rb"
36
+ template "channel.rb.tt", "app/channels/application_cable/channel.rb"
37
+ template "presence_channel.rb.tt", "app/channels/presence_channel.rb"
38
+ end
39
+
40
+ def show_post_install_message
41
+ say ""
42
+ say "WhereIsWaldo installed successfully!", :green
43
+ say ""
44
+ say "Next steps:"
45
+ say " 1. Review config/initializers/where_is_waldo.rb"
46
+ if adapter == "redis"
47
+ say " 2. Ensure REDIS_URL is configured"
48
+ else
49
+ say " 2. Run: rails db:migrate"
50
+ end
51
+ say " 3. Configure your frontend PresenceProvider:"
52
+ say ""
53
+ say " <PresenceProvider config={{ channelName: 'PresenceChannel' }}>"
54
+ say " <App />"
55
+ say " </PresenceProvider>"
56
+ say ""
57
+ end
58
+
59
+ private
60
+
61
+ def migration_version
62
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
63
+ end
64
+
65
+ def subject_class
66
+ options[:subject_class]
67
+ end
68
+
69
+ def subject_column
70
+ options[:subject_column]
71
+ end
72
+
73
+ def session_column
74
+ options[:session_column]
75
+ end
76
+
77
+ def auth_method
78
+ options[:auth_method]
79
+ end
80
+
81
+ def adapter
82
+ options[:adapter]
83
+ end
84
+
85
+ # Detect database adapter and return appropriate JSON column type
86
+ def json_column_type
87
+ adapter_name = ActiveRecord::Base.connection.adapter_name.downcase
88
+ case adapter_name
89
+ when /postgresql/, /postgis/
90
+ :jsonb
91
+ when /mysql/, /trilogy/
92
+ :json
93
+ else
94
+ # SQLite and others - use text with serialization
95
+ :text
96
+ end
97
+ rescue StandardError
98
+ # If we can't detect, default to text (most portable)
99
+ :text
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApplicationCable
4
+ class Channel < ActionCable::Channel::Base
5
+ end
6
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApplicationCable
4
+ class Connection < ActionCable::Connection::Base
5
+ <%- if auth_method == "jwt" -%>
6
+ # Turnkey corebyscott JWT auth: decodes the ?token= param, identifies the
7
+ # connection by current_user / current_container / session_id. Requires
8
+ # corebyscott. See WhereIsWaldo::JwtConnection to customize.
9
+ include WhereIsWaldo::JwtConnection
10
+ <%- elsif auth_method == "devise" -%>
11
+ identified_by :current_<%= subject_column.sub(/_id$/, '') %>, :session_id
12
+
13
+ def connect
14
+ self.current_<%= subject_column.sub(/_id$/, '') %> = find_verified_subject
15
+ self.session_id = find_session_id
16
+ end
17
+
18
+ private
19
+
20
+ def find_verified_subject
21
+ # Find user from Devise session cookie
22
+ env["warden"].user || reject_unauthorized_connection
23
+ end
24
+
25
+ def find_session_id
26
+ # Use Warden session key or generate new one
27
+ request.session.id || SecureRandom.uuid
28
+ end
29
+ <%- else -%>
30
+ identified_by :current_<%= subject_column.sub(/_id$/, '') %>, :session_id
31
+
32
+ def connect
33
+ self.current_<%= subject_column.sub(/_id$/, '') %> = find_verified_subject
34
+ self.session_id = find_session_id
35
+ end
36
+
37
+ private
38
+
39
+ def find_verified_subject
40
+ # TODO: Implement your authentication logic here
41
+ # Return the authenticated subject or call reject_unauthorized_connection
42
+ raise NotImplementedError, "Implement find_verified_subject in ApplicationCable::Connection"
43
+ end
44
+
45
+ def find_session_id
46
+ # TODO: Return a unique session identifier (unique per browser tab)
47
+ SecureRandom.uuid
48
+ end
49
+ <%- end -%>
50
+ end
51
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Create<%= table_name.camelize %> < ActiveRecord::Migration[7.0]
4
+ def change
5
+ create_table :<%= table_name %> do |t|
6
+ # Identifiers - column names are configurable
7
+ t.string :<%= session_column %>, null: false # Unique session/connection identifier
8
+ t.bigint :<%= subject_column %>, null: false # Who (user, member, student, etc.)
9
+
10
+ # Timestamps
11
+ t.datetime :connected_at, null: false
12
+ t.datetime :last_heartbeat, null: false
13
+ t.datetime :last_activity
14
+
15
+ # Activity state
16
+ t.boolean :tab_visible, default: true, null: false
17
+ t.boolean :subject_active, default: true, null: false
18
+
19
+ # Extensible metadata
20
+ t.<%= json_column_type %> :metadata
21
+
22
+ t.timestamps
23
+ end
24
+
25
+ # Session must be unique
26
+ add_index :<%= table_name %>, :<%= session_column %>, unique: true
27
+
28
+ # Query by subject
29
+ add_index :<%= table_name %>, :<%= subject_column %>
30
+
31
+ # Cleanup old records
32
+ add_index :<%= table_name %>, :last_heartbeat
33
+
34
+ <% if subject_table.present? -%>
35
+ # Foreign key to subject table
36
+ add_foreign_key :<%= table_name %>, :<%= subject_table %>, column: :<%= subject_column %>
37
+ <% end -%>
38
+ end
39
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ WhereIsWaldo.configure do |config|
4
+ # Storage adapter: :database or :redis
5
+ config.adapter = :<%= adapter %>
6
+ <% if adapter == "redis" %>
7
+ # Redis configuration
8
+ # config.redis_client = Redis.new(url: ENV["REDIS_URL"])
9
+ # config.redis_prefix = "where_is_waldo"
10
+ <% else %>
11
+ # Table name for presence records
12
+ config.table_name = "presences"
13
+ <% end %>
14
+
15
+ # Column names
16
+ config.session_column = :<%= session_column %>
17
+ config.subject_column = :<%= subject_column %>
18
+
19
+ # Subject model class
20
+ config.subject_class = "<%= subject_class %>"
21
+
22
+ # Build subject data for presence responses
23
+ config.subject_data_proc = ->(subject) {
24
+ return {} unless subject
25
+ {
26
+ id: subject.id,
27
+ # Add more fields as needed:
28
+ # first_name: subject.first_name,
29
+ # last_name: subject.last_name,
30
+ # email: subject.email,
31
+ }
32
+ }
33
+
34
+ # Timing
35
+ config.timeout = 90 # seconds until considered offline
36
+ config.heartbeat_interval = 30 # expected heartbeat frequency
37
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreatePresences < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ create_table :presences do |t|
6
+ t.string :<%= session_column %>, null: false
7
+ t.references :<%= subject_column.sub(/_id$/, '') %>, null: false, foreign_key: true
8
+ t.datetime :connected_at, null: false
9
+ t.datetime :last_heartbeat, null: false
10
+ t.datetime :last_activity
11
+ t.boolean :tab_visible, default: true, null: false
12
+ t.boolean :subject_active, default: true, null: false
13
+ t.<%= json_column_type %> :metadata
14
+ t.timestamps
15
+ end
16
+
17
+ add_index :presences, :<%= session_column %>, unique: true
18
+ add_index :presences, :last_heartbeat
19
+ end
20
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ class PresenceChannel < ApplicationCable::Channel
4
+ def subscribed
5
+ stream_from subject_stream
6
+ register_presence
7
+ end
8
+
9
+ def unsubscribed
10
+ WhereIsWaldo.disconnect(session_id: connection.session_id)
11
+ end
12
+
13
+ def heartbeat(data)
14
+ data = data.with_indifferent_access
15
+
16
+ WhereIsWaldo.heartbeat(
17
+ session_id: connection.session_id,
18
+ tab_visible: data[:tab_visible] != false,
19
+ subject_active: data[:subject_active] != false,
20
+ metadata: data[:metadata] || {}
21
+ )
22
+ end
23
+
24
+ private
25
+
26
+ def register_presence
27
+ WhereIsWaldo.connect(
28
+ session_id: connection.session_id,
29
+ subject_id: connection.current_<%= subject_column.sub(/_id$/, '') %>.id,
30
+ metadata: params[:metadata] || {}
31
+ )
32
+ end
33
+
34
+ def subject_stream
35
+ "where_is_waldo:subject:#{connection.current_<%= subject_column.sub(/_id$/, '') %>.id}"
36
+ end
37
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhereIsWaldo
4
+ class Configuration
5
+ # Storage settings
6
+ # :adapter - :database or :redis
7
+ # :table_name - defaults to 'presences'
8
+ # :redis_client - custom Redis instance (optional)
9
+ # :redis_prefix - key prefix for Redis (for multi-app setups)
10
+ attr_accessor :adapter, :table_name, :redis_client, :redis_prefix
11
+
12
+ # Column names - fully configurable
13
+ # :session_column - unique identifier per connection/tab (e.g., :session_id, :jti)
14
+ # :subject_column - who is present (e.g., :user_id, :member_id, :student_id)
15
+ attr_accessor :session_column, :subject_column
16
+
17
+ # Subject model (e.g., 'User', 'Member', 'Student')
18
+ # Required for scope-based broadcasting and queries
19
+ attr_accessor :subject_class
20
+
21
+ # Optional: proc that returns hash of subject info for presence data
22
+ # Called with the subject record to build presence hash
23
+ attr_accessor :subject_data_proc
24
+
25
+ # Timing
26
+ # :timeout - seconds until considered offline (default: 90)
27
+ # :heartbeat_interval - expected heartbeat frequency (default: 30)
28
+ attr_accessor :timeout, :heartbeat_interval
29
+
30
+ # ActionCable settings
31
+ # :channel_name - defaults to 'WhereIsWaldo::PresenceChannel'
32
+ # :authenticate_proc - proc to authenticate connection, receives request
33
+ attr_accessor :channel_name, :authenticate_proc
34
+
35
+ # Default audience resolver for the Broadcastable concern. A lambda that,
36
+ # given a record, returns the AR scope to broadcast to (e.g. that record's
37
+ # account members). Set once per app to match its container, e.g.:
38
+ # config.broadcast_audience = ->(rec) { rec.account.users }
39
+ # Models may override per-model via `broadcasts_realtime(scope: ...)`.
40
+ attr_accessor :broadcast_audience
41
+
42
+ # === Live presence roster ("who's around in my org/account") ===
43
+ #
44
+ # roster_org: given a subject, return the org/container record that
45
+ # defines the roster boundary. Its class + id key the single shared roster
46
+ # ActionCable stream, so every member of the same org subscribes to ONE
47
+ # stream and a presence change is a single O(1) broadcast (not per-member
48
+ # fan-out). Required to enable the roster feature — nil leaves it inert.
49
+ # config.roster_org = ->(subject) { subject.account }
50
+ #
51
+ # roster_members: given that org, return the AR scope of member subjects
52
+ # shown in the roster. Optional — defaults to org.public_send(<subjects>)
53
+ # inferred from subject_class (User => :users). Provide it to scope the
54
+ # list, e.g. only active members:
55
+ # config.roster_members = ->(org) { org.users.active }
56
+ #
57
+ # roster_members_association: association used to build the default roster
58
+ # from the org when roster_members is unset (defaults to the pluralized
59
+ # subject_class, e.g. :users).
60
+ attr_accessor :roster_org, :roster_members, :roster_members_association
61
+
62
+ # roster_visible_to: given a VIEWER, return the AR scope of subjects
63
+ # that viewer may see (for :poll/:nudge snapshots + diffs). Handles any
64
+ # visibility rule server-side. Defaults to the viewer's whole org roster
65
+ # (everyone-sees-everyone) when unset.
66
+ # config.roster_visible_to = ->(viewer) { viewer.visible_users }
67
+ #
68
+ # roster_viewers_of: given a SUBJECT, return the AR scope of viewers allowed
69
+ # to see it (ONLY used by :fanout, Phase 3). Must be the exact inverse of
70
+ # roster_visible_to.
71
+ attr_accessor :roster_visible_to, :roster_viewers_of
72
+
73
+ # roster_mode: delivery strategy, per account. A symbol, or a callable
74
+ # resolving an account -> mode. MUST be a function of the account (uniform
75
+ # for all its members). Default :poll (safe: server-side filtered).
76
+ # :poll - heartbeat/poll, server-filtered, ~interval latency
77
+ # :broadcast - instant shared-stream push, NO filtering (open account)
78
+ # :nudge - :poll + content-free trigger (Phase 2)
79
+ # :fanout - per-viewer push (Phase 3)
80
+ # config.roster_mode = ->(account) { account.everyone_admin? ? :broadcast : :poll }
81
+ attr_accessor :roster_mode
82
+
83
+ # Tuning (pull/nudge). roster_nudge_jitter (seconds) spreads clients' re-poll
84
+ # after a nudge so a change doesn't stampede every viewer at once.
85
+ attr_accessor :roster_poll_interval, :roster_cache_ttl, :roster_nudge_jitter
86
+
87
+ def initialize
88
+ # Storage defaults
89
+ @adapter = :database
90
+ @table_name = "presences"
91
+ @redis_client = nil
92
+ @redis_prefix = "where_is_waldo"
93
+
94
+ # Column defaults
95
+ @session_column = :session_id
96
+ @subject_column = :subject_id
97
+
98
+ # Subject model (required)
99
+ @subject_class = nil
100
+ @subject_data_proc = nil
101
+
102
+ # Timing defaults
103
+ @timeout = 90
104
+ @heartbeat_interval = 30
105
+
106
+ # ActionCable defaults
107
+ @channel_name = "WhereIsWaldo::PresenceChannel"
108
+ @authenticate_proc = nil
109
+
110
+ # Broadcastable default audience (set per app)
111
+ @broadcast_audience = nil
112
+
113
+ # Presence roster (set per app to enable)
114
+ @roster_org = nil
115
+ @roster_members = nil
116
+ @roster_members_association = nil
117
+ @roster_visible_to = nil
118
+ @roster_viewers_of = nil
119
+ @roster_mode = :poll
120
+ @roster_poll_interval = 15
121
+ @roster_cache_ttl = 90
122
+ @roster_nudge_jitter = 0.5
123
+ end
124
+
125
+ # Helper to get timeout as duration
126
+ def timeout_duration
127
+ timeout.is_a?(ActiveSupport::Duration) ? timeout : timeout.seconds
128
+ end
129
+
130
+ # Helper to get subject class constant
131
+ def subject_class_constant
132
+ return nil if subject_class.blank?
133
+
134
+ subject_class.is_a?(String) ? subject_class.safe_constantize : subject_class
135
+ end
136
+
137
+ # Resolve the org/container record for a subject (nil if unset/absent).
138
+ def resolve_roster_org(subject)
139
+ return nil unless roster_org && subject
140
+
141
+ roster_org.call(subject)
142
+ end
143
+
144
+ # Association name used to derive the default roster from an org when
145
+ # roster_members is not configured (e.g. subject_class "User" => :users).
146
+ def members_association
147
+ return roster_members_association if roster_members_association
148
+ return nil if subject_class.blank?
149
+
150
+ subject_class.to_s.demodulize.underscore.pluralize.to_sym
151
+ end
152
+
153
+ # Resolve the AR scope of member subjects for an org (nil if not resolvable).
154
+ def resolve_members(org)
155
+ return nil unless org
156
+
157
+ if roster_members
158
+ roster_members.call(org)
159
+ elsif (assoc = members_association) && org.respond_to?(assoc)
160
+ org.public_send(assoc)
161
+ end
162
+ end
163
+
164
+ # True when the live-presence roster feature is configured.
165
+ def roster_enabled?
166
+ !roster_org.nil?
167
+ end
168
+
169
+ # Resolve the AR scope of subjects a VIEWER may see. Defaults to the
170
+ # viewer's whole org roster (everyone-sees-everyone) when unset.
171
+ def resolve_visible_to(viewer)
172
+ return nil unless viewer
173
+
174
+ if roster_visible_to
175
+ roster_visible_to.call(viewer)
176
+ else
177
+ resolve_members(resolve_roster_org(viewer))
178
+ end
179
+ end
180
+
181
+ # Resolve the AR scope of viewers allowed to see a SUBJECT (:fanout only).
182
+ def resolve_viewers_of(subject)
183
+ return nil unless roster_viewers_of && subject
184
+
185
+ roster_viewers_of.call(subject)
186
+ end
187
+
188
+ # Resolve the delivery mode for an account. Callable roster_mode is invoked
189
+ # with the account; a bare symbol is returned as-is. Defaults to :poll.
190
+ def resolve_mode(account)
191
+ mode = roster_mode
192
+ mode = mode.call(account) if mode.respond_to?(:call)
193
+ (mode || :poll).to_sym
194
+ end
195
+
196
+ # Build subject data hash from a subject record
197
+ def build_subject_data(subject)
198
+ return {} unless subject
199
+
200
+ if subject_data_proc
201
+ subject_data_proc.call(subject)
202
+ elsif subject.respond_to?(:id)
203
+ { id: subject.id }
204
+ else
205
+ {}
206
+ end
207
+ end
208
+ end
209
+ end