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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +467 -0
- data/VERSION +1 -0
- data/app/channels/where_is_waldo/application_cable/channel.rb +19 -0
- data/app/channels/where_is_waldo/application_cable/connection.rb +45 -0
- data/app/channels/where_is_waldo/jwt_connection.rb +83 -0
- data/app/channels/where_is_waldo/presence_channel.rb +86 -0
- data/app/channels/where_is_waldo/roster_channel.rb +52 -0
- data/app/jobs/where_is_waldo/application_job.rb +6 -0
- data/app/jobs/where_is_waldo/presence_cleanup_job.rb +13 -0
- data/app/models/concerns/where_is_waldo/broadcastable.rb +85 -0
- data/app/models/where_is_waldo/application_record.rb +7 -0
- data/app/models/where_is_waldo/presence.rb +87 -0
- data/app/services/where_is_waldo/adapters/base_adapter.rb +80 -0
- data/app/services/where_is_waldo/adapters/database_adapter.rb +104 -0
- data/app/services/where_is_waldo/adapters/redis_adapter.rb +205 -0
- data/app/services/where_is_waldo/broadcaster.rb +79 -0
- data/app/services/where_is_waldo/presence_service.rb +119 -0
- data/app/services/where_is_waldo/roster.rb +246 -0
- data/app/services/where_is_waldo/roster_delivery.rb +184 -0
- data/lib/generators/where_is_waldo/install/install_generator.rb +103 -0
- data/lib/generators/where_is_waldo/install/templates/channel.rb.tt +6 -0
- data/lib/generators/where_is_waldo/install/templates/connection.rb.tt +51 -0
- data/lib/generators/where_is_waldo/install/templates/create_presences.rb.tt +39 -0
- data/lib/generators/where_is_waldo/install/templates/initializer.rb.tt +37 -0
- data/lib/generators/where_is_waldo/install/templates/migration.rb.tt +20 -0
- data/lib/generators/where_is_waldo/install/templates/presence_channel.rb.tt +37 -0
- data/lib/where_is_waldo/configuration.rb +209 -0
- data/lib/where_is_waldo/engine.rb +17 -0
- data/lib/where_is_waldo/version.rb +5 -0
- data/lib/where_is_waldo.rb +117 -0
- metadata +249 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhereIsWaldo
|
|
4
|
+
class PresenceChannel < ApplicationCable::Channel
|
|
5
|
+
def subscribed
|
|
6
|
+
stream_from subject_stream
|
|
7
|
+
|
|
8
|
+
register_presence
|
|
9
|
+
|
|
10
|
+
# Seed the local transition gate, resolve the roster delivery strategy for
|
|
11
|
+
# this subject's account once, and announce arrival.
|
|
12
|
+
@wiw_tab_visible = true
|
|
13
|
+
@wiw_subject_active = true
|
|
14
|
+
@wiw_roster_strategy = resolve_roster_strategy
|
|
15
|
+
publish_roster_change
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def unsubscribed
|
|
19
|
+
WhereIsWaldo.disconnect(session_id: waldo_session_id)
|
|
20
|
+
|
|
21
|
+
# Recompute the subject's aggregate (they may still be present in another
|
|
22
|
+
# tab/device) and announce the change to the org roster.
|
|
23
|
+
publish_roster_change
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def heartbeat(data)
|
|
27
|
+
data = data.with_indifferent_access
|
|
28
|
+
|
|
29
|
+
tab_visible = data[:tab_visible] != false
|
|
30
|
+
subject_active = data[:subject_active] != false
|
|
31
|
+
|
|
32
|
+
WhereIsWaldo.heartbeat(
|
|
33
|
+
session_id: waldo_session_id,
|
|
34
|
+
tab_visible: tab_visible,
|
|
35
|
+
subject_active: subject_active,
|
|
36
|
+
last_activity_at: data[:last_activity_at],
|
|
37
|
+
metadata: data[:metadata] || {}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Efficiency: only touch the roster when THIS session's activity/visibility
|
|
41
|
+
# actually flips. Steady-state heartbeats (no change) cost zero broadcasts.
|
|
42
|
+
return unless roster_transition?(tab_visible, subject_active)
|
|
43
|
+
|
|
44
|
+
@wiw_tab_visible = tab_visible
|
|
45
|
+
@wiw_subject_active = subject_active
|
|
46
|
+
publish_roster_change
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def register_presence
|
|
52
|
+
WhereIsWaldo.connect(
|
|
53
|
+
session_id: waldo_session_id,
|
|
54
|
+
subject_id: waldo_subject_id,
|
|
55
|
+
metadata: params[:metadata] || {}
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def roster_transition?(tab_visible, subject_active)
|
|
60
|
+
tab_visible != @wiw_tab_visible || subject_active != @wiw_subject_active
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Resolve the roster delivery strategy for this subject's account, once, at
|
|
64
|
+
# subscribe. nil when the roster isn't configured or the subject has no org.
|
|
65
|
+
def resolve_roster_strategy
|
|
66
|
+
return nil unless WhereIsWaldo.config.roster_enabled?
|
|
67
|
+
|
|
68
|
+
subject = WhereIsWaldo.config.subject_class_constant&.find_by(id: waldo_subject_id)
|
|
69
|
+
org = subject && WhereIsWaldo.config.resolve_roster_org(subject)
|
|
70
|
+
return nil unless org
|
|
71
|
+
|
|
72
|
+
WhereIsWaldo::RosterDelivery.for(WhereIsWaldo.config.resolve_mode(org))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Let the delivery strategy react to a presence transition. For :poll this
|
|
76
|
+
# is a no-op (clients poll); for :broadcast it pushes a delta to the account
|
|
77
|
+
# stream. Gated to actual transitions by the heartbeat handler.
|
|
78
|
+
def publish_roster_change
|
|
79
|
+
@wiw_roster_strategy&.on_transition(waldo_subject_id)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def subject_stream
|
|
83
|
+
"where_is_waldo:subject:#{waldo_subject_id}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhereIsWaldo
|
|
4
|
+
# Roster subscription. The delivery mode is resolved per account at subscribe
|
|
5
|
+
# time (config.roster_mode) and dispatched to the matching RosterDelivery
|
|
6
|
+
# strategy; the client is told the mode via the snapshot and adapts (listen
|
|
7
|
+
# vs. poll). A subject can only ever see their OWN account's roster (org is
|
|
8
|
+
# derived from the authenticated connection), so it's safe by construction.
|
|
9
|
+
#
|
|
10
|
+
# Modes:
|
|
11
|
+
# :broadcast - stream_from the shared account stream; deltas are pushed.
|
|
12
|
+
# :poll - no stream; the client calls #poll and gets a filtered diff.
|
|
13
|
+
class RosterChannel < ApplicationCable::Channel
|
|
14
|
+
def subscribed
|
|
15
|
+
subject = current_subject
|
|
16
|
+
mode = resolve_mode(subject)
|
|
17
|
+
return reject unless mode
|
|
18
|
+
|
|
19
|
+
@wiw_subject = subject
|
|
20
|
+
@wiw_strategy = RosterDelivery.for(mode)
|
|
21
|
+
apply_plan(@wiw_strategy.subscribe_plan(subject, waldo_session_id))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Client-driven poll (pull/nudge modes). Transmits a filtered snapshot
|
|
25
|
+
# (first call / resync) or diff since the last poll for this session.
|
|
26
|
+
def poll
|
|
27
|
+
return unless @wiw_strategy
|
|
28
|
+
|
|
29
|
+
@wiw_strategy.poll_messages(@wiw_subject, waldo_session_id).each { |message| transmit(message) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def resolve_mode(subject)
|
|
35
|
+
org = WhereIsWaldo.config.resolve_roster_org(subject)
|
|
36
|
+
return nil unless org
|
|
37
|
+
|
|
38
|
+
WhereIsWaldo.config.resolve_mode(org)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def apply_plan(plan)
|
|
42
|
+
plan[:streams].each { |stream| stream_from(stream) }
|
|
43
|
+
plan[:messages].each { |message| transmit(message) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def current_subject
|
|
47
|
+
return nil unless waldo_subject_id
|
|
48
|
+
|
|
49
|
+
WhereIsWaldo.config.subject_class_constant&.find_by(id: waldo_subject_id)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhereIsWaldo
|
|
4
|
+
class PresenceCleanupJob < ApplicationJob
|
|
5
|
+
queue_as :default
|
|
6
|
+
|
|
7
|
+
def perform(timeout: nil)
|
|
8
|
+
cleaned = PresenceService.cleanup(timeout: timeout)
|
|
9
|
+
Rails.logger.info "[WhereIsWaldo] Cleaned up #{cleaned} stale presence records"
|
|
10
|
+
cleaned
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhereIsWaldo
|
|
4
|
+
# Opt a model into real-time broadcasting. Include the concern and declare
|
|
5
|
+
# which lifecycle events should push over the cable:
|
|
6
|
+
#
|
|
7
|
+
# class Person < ApplicationRecord
|
|
8
|
+
# include WhereIsWaldo::Broadcastable
|
|
9
|
+
# broadcasts_realtime # create + update + destroy
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# broadcasts_realtime on: %i[create update] # subset
|
|
13
|
+
# broadcasts_realtime on: :update, if: -> { saved_change_to_status? }
|
|
14
|
+
# broadcasts_realtime scope: ->(rec) { rec.organization.users }
|
|
15
|
+
#
|
|
16
|
+
# Event names follow "<model>_<verb>" — created / update / destroyed — e.g.
|
|
17
|
+
# person_created, person_update, person_destroyed (matching the convention
|
|
18
|
+
# issuesbyscott already uses). The client subscribes with useWaldoEvent.
|
|
19
|
+
#
|
|
20
|
+
# Audience defaults to `WhereIsWaldo.configuration.broadcast_audience` (set
|
|
21
|
+
# once per app, e.g. ->(rec) { rec.account.users }); override per model with
|
|
22
|
+
# `scope:`. Payload defaults to a minimal `{ id: }` (so clients refetch);
|
|
23
|
+
# override `realtime_payload` on the model to push a richer shape for
|
|
24
|
+
# patch-in-place rendering.
|
|
25
|
+
module Broadcastable
|
|
26
|
+
extend ActiveSupport::Concern
|
|
27
|
+
|
|
28
|
+
HOOKS = {
|
|
29
|
+
create: %i[after_create_commit created],
|
|
30
|
+
update: %i[after_update_commit update],
|
|
31
|
+
destroy: %i[after_destroy_commit destroyed]
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
class_methods do
|
|
35
|
+
def broadcasts_realtime(on: %i[create update destroy], scope: nil, **guard)
|
|
36
|
+
guard = guard.slice(:if, :unless)
|
|
37
|
+
Array(on).each do |event|
|
|
38
|
+
hook, verb = HOOKS.fetch(event.to_sym) do
|
|
39
|
+
raise ArgumentError, "broadcasts_realtime: unknown event #{event.inspect}"
|
|
40
|
+
end
|
|
41
|
+
# Block form runs in instance context; `verb`/`scope` are captured.
|
|
42
|
+
public_send(hook, **guard) { wiw_broadcast(verb, scope) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Override on the model to shape the payload (default: minimal { id: }).
|
|
48
|
+
def realtime_payload
|
|
49
|
+
{ id: id }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def wiw_broadcast(verb, scope)
|
|
55
|
+
return unless defined?(::WhereIsWaldo)
|
|
56
|
+
|
|
57
|
+
audience = wiw_audience(scope)
|
|
58
|
+
return if audience.blank?
|
|
59
|
+
|
|
60
|
+
::WhereIsWaldo.broadcast_to(audience, wiw_event_name(verb), wiw_payload(verb))
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
Rails.logger&.warn(
|
|
63
|
+
"[WhereIsWaldo::Broadcastable] #{self.class.name}##{id} #{verb} broadcast failed: #{e.class}: #{e.message}"
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def wiw_audience(scope)
|
|
68
|
+
resolver = scope || ::WhereIsWaldo.configuration.broadcast_audience
|
|
69
|
+
return nil unless resolver
|
|
70
|
+
|
|
71
|
+
instance_exec(self, &resolver)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def wiw_event_name(verb)
|
|
75
|
+
# Use the STI base class so subclasses (e.g. Widget::Grid) broadcast the
|
|
76
|
+
# base event name ("widget_update"), not the subclass ("grid_update").
|
|
77
|
+
klass = self.class.respond_to?(:base_class) ? self.class.base_class : self.class
|
|
78
|
+
"#{klass.model_name.element}_#{verb}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def wiw_payload(verb)
|
|
82
|
+
verb == :destroyed ? { id: id } : realtime_payload
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhereIsWaldo
|
|
4
|
+
class Presence < ApplicationRecord
|
|
5
|
+
self.table_name = -> { WhereIsWaldo.config.table_name }
|
|
6
|
+
|
|
7
|
+
# Ensure metadata column works with any database (jsonb, json, or text)
|
|
8
|
+
# This makes the column portable across PostgreSQL, MySQL, and SQLite
|
|
9
|
+
attribute :metadata, :json
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def table_name
|
|
13
|
+
tn = WhereIsWaldo.config.table_name
|
|
14
|
+
tn.respond_to?(:call) ? tn.call : tn
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def configure_associations!
|
|
18
|
+
subject_class = WhereIsWaldo.config.subject_class_constant
|
|
19
|
+
return unless subject_class
|
|
20
|
+
|
|
21
|
+
subject_col = WhereIsWaldo.config.subject_column
|
|
22
|
+
# rubocop:disable Rails/InverseOf -- dynamic association, inverse not applicable
|
|
23
|
+
belongs_to :subject,
|
|
24
|
+
class_name: subject_class.name,
|
|
25
|
+
foreign_key: subject_col,
|
|
26
|
+
optional: true
|
|
27
|
+
# rubocop:enable Rails/InverseOf
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Dynamic column accessors
|
|
31
|
+
def session_column
|
|
32
|
+
WhereIsWaldo.config.session_column
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def subject_column
|
|
36
|
+
WhereIsWaldo.config.subject_column
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Scopes
|
|
41
|
+
scope :online, lambda { |timeout: nil|
|
|
42
|
+
threshold = (timeout || WhereIsWaldo.config.timeout).seconds.ago
|
|
43
|
+
where("last_heartbeat > ?", threshold)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
scope :for_subject, lambda { |subject_id|
|
|
47
|
+
where(WhereIsWaldo.config.subject_column => subject_id)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
scope :for_subjects, lambda { |subject_ids|
|
|
51
|
+
where(WhereIsWaldo.config.subject_column => subject_ids)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
scope :visible_tab, -> { where(tab_visible: true) }
|
|
55
|
+
scope :active, -> { where(subject_active: true) }
|
|
56
|
+
|
|
57
|
+
# Convert to hash for API responses
|
|
58
|
+
def as_presence_hash
|
|
59
|
+
config = WhereIsWaldo.config
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
session_id: self[config.session_column],
|
|
63
|
+
subject_id: self[config.subject_column],
|
|
64
|
+
connected_at: connected_at&.iso8601,
|
|
65
|
+
last_heartbeat: last_heartbeat&.iso8601,
|
|
66
|
+
tab_visible: tab_visible,
|
|
67
|
+
subject_active: subject_active,
|
|
68
|
+
last_activity: last_activity&.iso8601,
|
|
69
|
+
metadata: metadata,
|
|
70
|
+
subject: subject_data
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Check if this presence is considered online
|
|
75
|
+
def online?(timeout: nil)
|
|
76
|
+
threshold = (timeout || WhereIsWaldo.config.timeout).seconds.ago
|
|
77
|
+
last_heartbeat && last_heartbeat > threshold
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def subject_data
|
|
83
|
+
subject_record = try(:subject)
|
|
84
|
+
WhereIsWaldo.config.build_subject_data(subject_record)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhereIsWaldo
|
|
4
|
+
module Adapters
|
|
5
|
+
class BaseAdapter
|
|
6
|
+
# Register a 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
|
+
raise NotImplementedError
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Remove a presence
|
|
16
|
+
# @param session_id [String] Session identifier (optional if subject_id provided)
|
|
17
|
+
# @param subject_id [Integer/String] Subject identifier (optional)
|
|
18
|
+
# @return [Boolean] success
|
|
19
|
+
def disconnect(session_id: nil, subject_id: nil)
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Update heartbeat
|
|
24
|
+
# @param session_id [String] Session identifier
|
|
25
|
+
# @param tab_visible [Boolean] Is tab in foreground
|
|
26
|
+
# @param subject_active [Boolean] Recent activity
|
|
27
|
+
# @param metadata [Hash] Additional data
|
|
28
|
+
# @return [Boolean] success
|
|
29
|
+
def heartbeat(session_id:, tab_visible: true, subject_active: true, metadata: {})
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Get all online subject IDs
|
|
34
|
+
# @param timeout [Integer] Seconds threshold
|
|
35
|
+
# @return [Array<Integer>] Subject IDs
|
|
36
|
+
def online_subject_ids(timeout: nil)
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Get all sessions for a subject
|
|
41
|
+
# @param subject_id [Integer/String] Subject identifier
|
|
42
|
+
# @return [Array<Hash>] Presence records
|
|
43
|
+
def sessions_for_subject(subject_id)
|
|
44
|
+
raise NotImplementedError
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Get session status
|
|
48
|
+
# @param session_id [String] Session identifier
|
|
49
|
+
# @return [Hash, nil] Presence record or nil
|
|
50
|
+
def session_status(session_id)
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Remove stale records
|
|
55
|
+
# @param timeout [Integer] Seconds threshold
|
|
56
|
+
# @return [Integer] Number removed
|
|
57
|
+
def cleanup(timeout: nil)
|
|
58
|
+
raise NotImplementedError
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
protected
|
|
62
|
+
|
|
63
|
+
def config
|
|
64
|
+
WhereIsWaldo.config
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def default_timeout
|
|
68
|
+
config.timeout
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def session_column
|
|
72
|
+
config.session_column
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def subject_column
|
|
76
|
+
config.subject_column
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhereIsWaldo
|
|
4
|
+
module Adapters
|
|
5
|
+
class DatabaseAdapter < BaseAdapter
|
|
6
|
+
def connect(session_id:, subject_id:, metadata: {})
|
|
7
|
+
now = Time.current
|
|
8
|
+
|
|
9
|
+
attrs = {
|
|
10
|
+
session_column => session_id,
|
|
11
|
+
subject_column => 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
|
+
created_at: now,
|
|
19
|
+
updated_at: now
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# rubocop:disable Rails/SkipsModelValidations -- intentional for performance
|
|
23
|
+
Presence.upsert(attrs, unique_by: session_column)
|
|
24
|
+
# rubocop:enable Rails/SkipsModelValidations
|
|
25
|
+
true
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
Rails.logger.error "[WhereIsWaldo] Connect failed: #{e.message}"
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def disconnect(session_id: nil, subject_id: nil)
|
|
32
|
+
scope = build_lookup_scope(session_id: session_id, subject_id: subject_id)
|
|
33
|
+
scope.delete_all
|
|
34
|
+
true
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
Rails.logger.error "[WhereIsWaldo] Disconnect failed: #{e.message}"
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def heartbeat(session_id:, tab_visible: true, subject_active: true, last_activity_at: nil, metadata: {})
|
|
41
|
+
now = Time.current
|
|
42
|
+
updates = {
|
|
43
|
+
last_heartbeat: now,
|
|
44
|
+
tab_visible: tab_visible,
|
|
45
|
+
subject_active: subject_active,
|
|
46
|
+
updated_at: now
|
|
47
|
+
}
|
|
48
|
+
# Update last_activity: use JS timestamp if provided, otherwise use current time when active
|
|
49
|
+
if last_activity_at
|
|
50
|
+
updates[:last_activity] = Time.zone.at(last_activity_at / 1000.0)
|
|
51
|
+
elsif subject_active
|
|
52
|
+
updates[:last_activity] = now
|
|
53
|
+
end
|
|
54
|
+
updates[:metadata] = metadata if metadata.present?
|
|
55
|
+
|
|
56
|
+
scope = Presence.where(session_column => session_id)
|
|
57
|
+
|
|
58
|
+
# rubocop:disable Rails/SkipsModelValidations -- intentional for performance
|
|
59
|
+
scope.update_all(updates).positive?
|
|
60
|
+
# rubocop:enable Rails/SkipsModelValidations
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
Rails.logger.error "[WhereIsWaldo] Heartbeat failed: #{e.message}"
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def online_subject_ids(timeout: nil)
|
|
67
|
+
threshold = (timeout || default_timeout).seconds.ago
|
|
68
|
+
|
|
69
|
+
Presence.where("last_heartbeat > ?", threshold)
|
|
70
|
+
.distinct
|
|
71
|
+
.pluck(subject_column)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def sessions_for_subject(subject_id)
|
|
75
|
+
scope = Presence.where(subject_column => subject_id)
|
|
76
|
+
scope = scope.includes(:subject) if config.subject_class_constant
|
|
77
|
+
scope.map(&:as_presence_hash)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def session_status(session_id)
|
|
81
|
+
scope = Presence.where(session_column => session_id)
|
|
82
|
+
scope = scope.includes(:subject) if config.subject_class_constant
|
|
83
|
+
scope.first&.as_presence_hash
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def cleanup(timeout: nil)
|
|
87
|
+
threshold = (timeout || default_timeout).seconds.ago
|
|
88
|
+
Presence.where(last_heartbeat: ...threshold).delete_all
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def build_lookup_scope(session_id: nil, subject_id: nil)
|
|
94
|
+
if session_id
|
|
95
|
+
Presence.where(session_column => session_id)
|
|
96
|
+
elsif subject_id
|
|
97
|
+
Presence.where(subject_column => subject_id)
|
|
98
|
+
else
|
|
99
|
+
Presence.none
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|