From a7a3ef092d61fbaf953e4f7eeb2a87cc1d736250 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:11:26 +1000 Subject: [PATCH] feat: live task and comment updates via action cable --- app/channels/application_cable/connection.rb | 19 ++++++++++++++++++- app/channels/task_channel.rb | 13 +++++++++++++ app/models/comments/task_comment.rb | 6 ++++++ app/models/task.rb | 5 +++++ config/cable.yml | 10 ++++++++++ config/routes.rb | 1 + 6 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 app/channels/task_channel.rb create mode 100644 config/cable.yml diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index 2fbbbca7d3..7b366c8a46 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -9,11 +9,28 @@ def connect protected def find_verified_user - if verified_user = env['warden'].user + if verified_user = env['warden'].user || user_from_token verified_user else reject_unauthorized_connection end end + + def user_from_token + username = request.params[:username] + auth_token = request.params[:authToken] || request.params[:auth_token] || request.params[:Auth_Token] + return if username.blank? || auth_token.blank? + + user = User.eager_load(:role, :auth_tokens).find_by(username: username) + token = user&.token_for_text?(auth_token, :general) + return if token.blank? + + if token.auth_token_expiry > Time.zone.now + user + else + token.destroy! + nil + end + end end end diff --git a/app/channels/task_channel.rb b/app/channels/task_channel.rb new file mode 100644 index 0000000000..83aa6ed4c5 --- /dev/null +++ b/app/channels/task_channel.rb @@ -0,0 +1,13 @@ +class TaskChannel < ApplicationCable::Channel + include AuthorisationHelpers + + def subscribed + project = Project.find_by(id: params[:project_id]) + task_definition = project&.unit&.task_definitions&.find_by(id: params[:task_definition_id]) + + return reject unless project && task_definition && authorise?(current_user, project, :get) + return reject unless project.has_task_for_task_definition?(task_definition) + + stream_for project.task_for_task_definition(task_definition) + end +end diff --git a/app/models/comments/task_comment.rb b/app/models/comments/task_comment.rb index c74883d014..87d5a724de 100644 --- a/app/models/comments/task_comment.rb +++ b/app/models/comments/task_comment.rb @@ -34,9 +34,15 @@ class TaskComment < ApplicationRecord mark_as_read(self.user) end + after_commit :broadcast_comment_created, on: :create + # Delete action - before dependent association before_destroy :delete_associated_files + def broadcast_comment_created + TaskChannel.broadcast_to(task, event: 'comment_created') + end + def valid_reply_to? if reply_to_id.present? originalTaskComment = TaskComment.find(reply_to_id) diff --git a/app/models/task.rb b/app/models/task.rb index 86439b3d08..52b2a3dc07 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -147,6 +147,7 @@ def specific_permission_hash(role, perm_hash, _other) delegate :update_task_stats, to: :project after_update :update_task_stats, if: :saved_change_to_task_status_id? # TODO: consider moving to async task + after_commit :broadcast_status_change, on: :update, if: :saved_change_to_task_status_id? validates :task_definition_id, uniqueness: { scope: :project, message: 'must be unique within the project' } @@ -897,6 +898,10 @@ def add_status_comment(current_user, status) comment end + def broadcast_status_change + TaskChannel.broadcast_to(self, event: 'status_changed') + end + def add_discussed_comment(current_user) comment = 'Discussed in class' diff --git a/config/cable.yml b/config/cable.yml new file mode 100644 index 0000000000..9ff209b00f --- /dev/null +++ b/config/cable.yml @@ -0,0 +1,10 @@ +development: + adapter: async + +test: + adapter: test + +production: + adapter: redis + url: <%= ENV.fetch("DF_REDIS_CABLE_URL", ENV.fetch("DF_REDIS_SIDEKIQ_URL", "redis://localhost:6379/1")) %> + channel_prefix: doubtfire_production diff --git a/config/routes.rb b/config/routes.rb index ea52a79000..5b9b4c7b10 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -7,6 +7,7 @@ get 'api/units/:id/all_resources', to: 'lecture_resource_downloads#index' mount ApiRoot => '/' + mount ActionCable.server => '/cable' mount GrapeSwaggerRails::Engine => '/api/docs' mount Sidekiq::Web => "/sidekiq" # mount Sidekiq::Web in your Rails app