mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
DEV: Chat service object initial implementation (#19814)
This is a combined work of Martin Brennan, Loïc Guitaut, and Joffrey Jaffeux. --- This commit implements a base service object when working in chat. The documentation is available at https://discourse.github.io/discourse/chat/backend/Chat/Service.html Generating documentation has been made as part of this commit with a bigger goal in mind of generally making it easier to dive into the chat project. Working with services generally involves 3 parts: - The service object itself, which is a series of steps where few of them are specialized (model, transaction, policy) ```ruby class UpdateAge include Chat::Service::Base model :user, :fetch_user policy :can_see_user contract step :update_age class Contract attribute :age, :integer end def fetch_user(user_id:, **) User.find_by(id: user_id) end def can_see_user(guardian:, **) guardian.can_see_user(user) end def update_age(age:, **) user.update!(age: age) end end ``` - The `with_service` controller helper, handling success and failure of the service within a service and making easy to return proper response to it from the controller ```ruby def update with_service(UpdateAge) do on_success { render_serialized(result.user, BasicUserSerializer, root: "user") } end end ``` - Rspec matchers and steps inspector, improving the dev experience while creating specs for a service ```ruby RSpec.describe(UpdateAge) do subject(:result) do described_class.call(guardian: guardian, user_id: user.id, age: age) end fab!(:user) { Fabricate(:user) } fab!(:current_user) { Fabricate(:admin) } let(:guardian) { Guardian.new(current_user) } let(:age) { 1 } it { expect(user.reload.age).to eq(age) } end ``` Note in case of unexpected failure in your spec, the output will give all the relevant information: ``` 1) UpdateAge when no channel_id is given is expected to fail to find a model named 'user' Failure/Error: it { is_expected.to fail_to_find_a_model(:user) } Expected model 'foo' (key: 'result.model.user') was not found in the result object. [1/4] [model] 'user' ❌ [2/4] [policy] 'can_see_user' [3/4] [contract] 'default' [4/4] [step] 'update_age' /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/update_age.rb:32:in `fetch_user': missing keyword: :user_id (ArgumentError) from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:202:in `instance_exec' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:202:in `call' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:219:in `call' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:417:in `block in run!' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:417:in `each' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:417:in `run!' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:411:in `run' from <internal:kernel>:90:in `tap' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:302:in `call' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/spec/services/update_age_spec.rb:15:in `block (3 levels) in <main>' ```
This commit is contained in:
@@ -29,37 +29,9 @@ class Chat::Api::ChatChannelsController < Chat::Api
|
||||
end
|
||||
|
||||
def destroy
|
||||
confirmation = params.require(:channel).require(:name_confirmation)&.downcase
|
||||
guardian.ensure_can_delete_chat_channel!
|
||||
|
||||
if channel_from_params.title(current_user).downcase != confirmation
|
||||
raise Discourse::InvalidParameters.new(:name_confirmation)
|
||||
with_service Chat::Service::TrashChannel do
|
||||
on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound }
|
||||
end
|
||||
|
||||
begin
|
||||
ChatChannel.transaction do
|
||||
channel_from_params.update!(
|
||||
slug:
|
||||
"#{Time.now.strftime("%Y%m%d-%H%M")}-#{channel_from_params.slug}-deleted".truncate(
|
||||
SiteSetting.max_topic_title_length,
|
||||
omission: "",
|
||||
),
|
||||
)
|
||||
channel_from_params.trash!(current_user)
|
||||
StaffActionLogger.new(current_user).log_custom(
|
||||
"chat_channel_delete",
|
||||
{
|
||||
chat_channel_id: channel_from_params.id,
|
||||
chat_channel_name: channel_from_params.title(current_user),
|
||||
},
|
||||
)
|
||||
end
|
||||
rescue ActiveRecord::Rollback
|
||||
return render_json_error(I18n.t("chat.errors.delete_channel_failed"))
|
||||
end
|
||||
|
||||
Jobs.enqueue(:chat_channel_delete, { chat_channel_id: channel_from_params.id })
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -118,37 +90,25 @@ class Chat::Api::ChatChannelsController < Chat::Api
|
||||
end
|
||||
|
||||
def update
|
||||
guardian.ensure_can_edit_chat_channel!
|
||||
|
||||
if channel_from_params.direct_message_channel?
|
||||
raise Discourse::InvalidParameters.new(
|
||||
I18n.t("chat.errors.cant_update_direct_message_channel"),
|
||||
)
|
||||
end
|
||||
|
||||
params_to_edit = editable_params(params, channel_from_params)
|
||||
params_to_edit.each { |k, v| params_to_edit[k] = nil if params_to_edit[k].blank? }
|
||||
|
||||
if ActiveRecord::Type::Boolean.new.deserialize(params_to_edit[:auto_join_users])
|
||||
auto_join_limiter(channel_from_params).performed!
|
||||
end
|
||||
|
||||
channel_from_params.update!(params_to_edit)
|
||||
|
||||
ChatPublisher.publish_chat_channel_edit(channel_from_params, current_user)
|
||||
|
||||
if channel_from_params.category_channel? && channel_from_params.auto_join_users
|
||||
Chat::ChatChannelMembershipManager.new(
|
||||
channel_from_params,
|
||||
).enforce_automatic_channel_memberships
|
||||
with_service(Chat::Service::UpdateChannel, **params_to_edit) do
|
||||
on_success do
|
||||
render_serialized(
|
||||
result.channel,
|
||||
ChatChannelSerializer,
|
||||
root: "channel",
|
||||
membership: result.channel.membership_for(current_user),
|
||||
)
|
||||
end
|
||||
on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound }
|
||||
on_failed_policy(:check_channel_permission) { raise Discourse::InvalidAccess }
|
||||
on_failed_policy(:no_direct_message_channel) { raise Discourse::InvalidAccess }
|
||||
end
|
||||
|
||||
render_serialized(
|
||||
channel_from_params,
|
||||
ChatChannelSerializer,
|
||||
root: "channel",
|
||||
membership: channel_from_params.membership_for(current_user),
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -2,17 +2,10 @@
|
||||
|
||||
class Chat::Api::ChatChannelsStatusController < Chat::Api::ChatChannelsController
|
||||
def update
|
||||
status = params.require(:status)
|
||||
|
||||
# we only want to use this endpoint for open/closed status changes,
|
||||
# the others are more "special" and are handled by the archive endpoint
|
||||
if !ChatChannel.statuses.keys.include?(status) || status == "read_only" || status == "archive"
|
||||
raise Discourse::InvalidParameters
|
||||
with_service(Chat::Service::UpdateChannelStatus) do
|
||||
on_success { render_serialized(result.channel, ChatChannelSerializer, root: "channel") }
|
||||
on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound }
|
||||
on_failed_policy(:check_channel_permission) { raise Discourse::InvalidAccess }
|
||||
end
|
||||
|
||||
guardian.ensure_can_change_channel_status!(channel_from_params, status.to_sym)
|
||||
channel_from_params.public_send("#{status}!", current_user)
|
||||
|
||||
render_serialized(channel_from_params, ChatChannelSerializer, root: "channel")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,6 +4,8 @@ class Chat::Api < Chat::ChatBaseController
|
||||
before_action :ensure_logged_in
|
||||
before_action :ensure_can_chat
|
||||
|
||||
include Chat::WithServiceHelper
|
||||
|
||||
private
|
||||
|
||||
def ensure_can_chat
|
||||
|
||||
37
plugins/chat/app/helpers/with_service_helper.rb
Normal file
37
plugins/chat/app/helpers/with_service_helper.rb
Normal file
@@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
module Chat
|
||||
module WithServiceHelper
|
||||
def result
|
||||
@_result
|
||||
end
|
||||
|
||||
def with_service(service, default_actions: true, **dependencies, &block)
|
||||
controller = self
|
||||
merged_block =
|
||||
proc do
|
||||
instance_eval(&controller.default_actions_for_service) if default_actions
|
||||
instance_eval(&(block || proc {}))
|
||||
end
|
||||
Chat::Endpoint.call(service, controller, **dependencies, &merged_block)
|
||||
end
|
||||
|
||||
def run_service(service, dependencies)
|
||||
@_result = service.call(params.to_unsafe_h.merge(guardian: guardian, **dependencies.to_h))
|
||||
end
|
||||
|
||||
def default_actions_for_service
|
||||
proc do
|
||||
on_success { render(json: success_json) }
|
||||
on_failure { render(json: failed_json, status: 422) }
|
||||
on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess }
|
||||
on_failed_contract do
|
||||
render(
|
||||
json:
|
||||
failed_json.merge(errors: result[:"result.contract.default"].errors.full_messages),
|
||||
status: 400,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -36,6 +36,10 @@ class ChatChannel < ActiveRecord::Base
|
||||
delegate :empty?, to: :chat_messages, prefix: true
|
||||
|
||||
class << self
|
||||
def editable_statuses
|
||||
statuses.filter { |k, _| !%w[read_only archived].include?(k) }
|
||||
end
|
||||
|
||||
def public_channel_chatable_types
|
||||
["Category"]
|
||||
end
|
||||
|
||||
427
plugins/chat/app/services/base.rb
Normal file
427
plugins/chat/app/services/base.rb
Normal file
@@ -0,0 +1,427 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
module Service
|
||||
# Module to be included to provide steps DSL to any class. This allows to
|
||||
# create easy to understand services as the whole service cycle is visible
|
||||
# simply by reading the beginning of its class.
|
||||
#
|
||||
# Steps are executed in the order they’re defined. They will use their name
|
||||
# to execute the corresponding method defined in the service class.
|
||||
#
|
||||
# Currently, there are 5 types of steps:
|
||||
#
|
||||
# * +model(name = :model)+: used to instantiate a model (either by building
|
||||
# it or fetching it from the DB). If a falsy value is returned, then the
|
||||
# step will fail. Otherwise the resulting object will be assigned in
|
||||
# +context[name]+ (+context[:model]+ by default).
|
||||
# * +policy(name = :default)+: used to perform a check on the state of the
|
||||
# system. Typically used to run guardians. If a falsy value is returned,
|
||||
# the step will fail.
|
||||
# * +contract(name = :default)+: used to validate the input parameters,
|
||||
# typically provided by a user calling an endpoint. A special embedded
|
||||
# +Contract+ class has to be defined to holds the validations. If the
|
||||
# validations fail, the step will fail. Otherwise, the resulting contract
|
||||
# will be available in +context[:contract]+.
|
||||
# * +step(name)+: used to run small snippets of arbitrary code. The step
|
||||
# doesn’t care about its return value, so to mark the service as failed,
|
||||
# {#fail!} has to be called explicitly.
|
||||
# * +transaction+: used to wrap other steps inside a DB transaction.
|
||||
#
|
||||
# The methods defined on the service are automatically provided with
|
||||
# the whole context passed as keyword arguments. This allows to define in a
|
||||
# very explicit way what dependencies are used by the method. If for
|
||||
# whatever reason a key isn’t found in the current context, then Ruby will
|
||||
# raise an exception when the method is called.
|
||||
#
|
||||
# Regarding contract classes, they have automatically {ActiveModel} modules
|
||||
# included so all the {ActiveModel} API is available.
|
||||
#
|
||||
# @example An example from the {TrashChannel} service
|
||||
# class TrashChannel
|
||||
# include Base
|
||||
#
|
||||
# model :channel, :fetch_channel
|
||||
# policy :invalid_access
|
||||
# transaction do
|
||||
# step :prevents_slug_collision
|
||||
# step :soft_delete_channel
|
||||
# step :log_channel_deletion
|
||||
# end
|
||||
# step :enqueue_delete_channel_relations_job
|
||||
#
|
||||
# private
|
||||
#
|
||||
# def fetch_channel(channel_id:, **)
|
||||
# ChatChannel.find_by(id: channel_id)
|
||||
# end
|
||||
#
|
||||
# def invalid_access(guardian:, channel:, **)
|
||||
# guardian.can_preview_chat_channel?(channel) && guardian.can_delete_chat_channel?
|
||||
# end
|
||||
#
|
||||
# def prevents_slug_collision(channel:, **)
|
||||
# …
|
||||
# end
|
||||
#
|
||||
# def soft_delete_channel(guardian:, channel:, **)
|
||||
# …
|
||||
# end
|
||||
#
|
||||
# def log_channel_deletion(guardian:, channel:, **)
|
||||
# …
|
||||
# end
|
||||
#
|
||||
# def enqueue_delete_channel_relations_job(channel:, **)
|
||||
# …
|
||||
# end
|
||||
# end
|
||||
# @example An example from the {UpdateChannelStatus} service which uses a contract
|
||||
# class UpdateChannelStatus
|
||||
# include Base
|
||||
#
|
||||
# model :channel, :fetch_channel
|
||||
# contract
|
||||
# policy :check_channel_permission
|
||||
# step :change_status
|
||||
#
|
||||
# class Contract
|
||||
# attribute :status
|
||||
# validates :status, inclusion: { in: ChatChannel.editable_statuses.keys }
|
||||
# end
|
||||
#
|
||||
# …
|
||||
# end
|
||||
module Base
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# The only exception that can be raised by a service.
|
||||
class Failure < StandardError
|
||||
# @return [Context]
|
||||
attr_reader :context
|
||||
|
||||
# @!visibility private
|
||||
def initialize(context = nil)
|
||||
@context = context
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
# Simple structure to hold the context of the service during its whole lifecycle.
|
||||
class Context < OpenStruct
|
||||
# @return [Boolean] returns +true+ if the conext is set as successful (default)
|
||||
def success?
|
||||
!failure?
|
||||
end
|
||||
|
||||
# @return [Boolean] returns +true+ if the context is set as failed
|
||||
# @see #fail!
|
||||
# @see #fail
|
||||
def failure?
|
||||
@failure || false
|
||||
end
|
||||
|
||||
# Marks the context as failed.
|
||||
# @param context [Hash, Context] the context to merge into the current one
|
||||
# @example
|
||||
# context.fail!("failure": "something went wrong")
|
||||
# @return [Context]
|
||||
def fail!(context = {})
|
||||
fail(context)
|
||||
raise Failure, self
|
||||
end
|
||||
|
||||
# Marks the context as failed without raising an exception.
|
||||
# @param context [Hash, Context] the context to merge into the current one
|
||||
# @example
|
||||
# context.fail("failure": "something went wrong")
|
||||
# @return [Context]
|
||||
def fail(context = {})
|
||||
merge(context)
|
||||
@failure = true
|
||||
self
|
||||
end
|
||||
|
||||
# Merges the given context into the current one.
|
||||
# @!visibility private
|
||||
def merge(other_context = {})
|
||||
other_context.each { |key, value| self[key.to_sym] = value }
|
||||
self
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def self.build(context = {})
|
||||
self === context ? context : new(context)
|
||||
end
|
||||
end
|
||||
|
||||
# Internal module to define available steps as DSL
|
||||
# @!visibility private
|
||||
module StepsHelpers
|
||||
def model(name = :model, step_name = :"fetch_#{name}")
|
||||
steps << ModelStep.new(name, step_name)
|
||||
end
|
||||
|
||||
def contract(name = :default, class_name: self::Contract, default_values_from: nil)
|
||||
steps << ContractStep.new(
|
||||
name,
|
||||
class_name: class_name,
|
||||
default_values_from: default_values_from,
|
||||
)
|
||||
end
|
||||
|
||||
def policy(name = :default)
|
||||
steps << PolicyStep.new(name)
|
||||
end
|
||||
|
||||
def step(name)
|
||||
steps << Step.new(name)
|
||||
end
|
||||
|
||||
def transaction(&block)
|
||||
steps << TransactionStep.new(&block)
|
||||
end
|
||||
end
|
||||
|
||||
# @!visibility private
|
||||
class Step
|
||||
attr_reader :name, :method_name, :class_name
|
||||
|
||||
def initialize(name, method_name = name, class_name: nil)
|
||||
@name = name
|
||||
@method_name = method_name
|
||||
@class_name = class_name
|
||||
end
|
||||
|
||||
def call(instance, context)
|
||||
method = instance.method(method_name)
|
||||
args = {}
|
||||
args = context.to_h unless method.arity.zero?
|
||||
context[result_key] = Context.build
|
||||
instance.instance_exec(**args, &method)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def type
|
||||
self.class.name.split("::").last.downcase.sub(/^(\w+)step$/, "\\1")
|
||||
end
|
||||
|
||||
def result_key
|
||||
"result.#{type}.#{name}"
|
||||
end
|
||||
end
|
||||
|
||||
# @!visibility private
|
||||
class ModelStep < Step
|
||||
def call(instance, context)
|
||||
context[name] = super
|
||||
raise ArgumentError, "Model not found" unless context[name]
|
||||
rescue ArgumentError => exception
|
||||
context[result_key].fail(exception: exception)
|
||||
context.fail!
|
||||
end
|
||||
end
|
||||
|
||||
# @!visibility private
|
||||
class PolicyStep < Step
|
||||
def call(instance, context)
|
||||
unless super
|
||||
context[result_key].fail
|
||||
context.fail!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# @!visibility private
|
||||
class ContractStep < Step
|
||||
attr_reader :default_values_from
|
||||
|
||||
def initialize(name, method_name = name, class_name: nil, default_values_from: nil)
|
||||
super(name, method_name, class_name: class_name)
|
||||
@default_values_from = default_values_from
|
||||
end
|
||||
|
||||
def call(instance, context)
|
||||
attributes = class_name.attribute_names.map(&:to_sym)
|
||||
default_values = {}
|
||||
default_values = context[default_values_from].slice(*attributes) if default_values_from
|
||||
contract = class_name.new(default_values.merge(context.to_h.slice(*attributes)))
|
||||
context[contract_name] = contract
|
||||
context[result_key] = Context.build
|
||||
unless contract.valid?
|
||||
context[result_key].fail(errors: contract.errors)
|
||||
context.fail!
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def contract_name
|
||||
return :contract if name.to_sym == :default
|
||||
:"#{name}_contract"
|
||||
end
|
||||
end
|
||||
|
||||
# @!visibility private
|
||||
class TransactionStep < Step
|
||||
include StepsHelpers
|
||||
|
||||
attr_reader :steps
|
||||
|
||||
def initialize(&block)
|
||||
@steps = []
|
||||
instance_exec(&block)
|
||||
end
|
||||
|
||||
def call(instance, context)
|
||||
ActiveRecord::Base.transaction { steps.each { |step| step.call(instance, context) } }
|
||||
end
|
||||
end
|
||||
|
||||
included do
|
||||
# The global context which is available from any step.
|
||||
attr_reader :context
|
||||
|
||||
# @!visibility private
|
||||
# Internal class used to setup the base contract of the service.
|
||||
self::Contract =
|
||||
Class.new do
|
||||
include ActiveModel::API
|
||||
include ActiveModel::Attributes
|
||||
include ActiveModel::AttributeMethods
|
||||
include ActiveModel::Validations::Callbacks
|
||||
end
|
||||
end
|
||||
|
||||
class_methods do
|
||||
include StepsHelpers
|
||||
|
||||
def call(context = {})
|
||||
new(context).tap(&:run).context
|
||||
end
|
||||
|
||||
def call!(context = {})
|
||||
new(context).tap(&:run!).context
|
||||
end
|
||||
|
||||
def steps
|
||||
@steps ||= []
|
||||
end
|
||||
end
|
||||
|
||||
# @!scope class
|
||||
# @!method model(name = :model, step_name = :"fetch_#{name}")
|
||||
# @param name [Symbol] name of the model
|
||||
# @param step_name [Symbol] name of the method to call for this step
|
||||
# Evaluates arbitrary code to build or fetch a model (typically from the
|
||||
# DB). If the step returns a falsy value, then the step will fail.
|
||||
#
|
||||
# It stores the resulting model in +context[:model]+ by default (can be
|
||||
# customized by providing the +name+ argument).
|
||||
#
|
||||
# @example
|
||||
# model :channel, :fetch_channel
|
||||
#
|
||||
# private
|
||||
#
|
||||
# def fetch_channel(channel_id:, **)
|
||||
# ChatChannel.find_by(id: channel_id)
|
||||
# end
|
||||
|
||||
# @!scope class
|
||||
# @!method policy(name = :default)
|
||||
# @param name [Symbol] name for this policy
|
||||
# Performs checks related to the state of the system. If the
|
||||
# step doesn’t return a truthy value, then the policy will fail.
|
||||
#
|
||||
# @example
|
||||
# policy :no_direct_message_channel
|
||||
#
|
||||
# private
|
||||
#
|
||||
# def no_direct_message_channel(channel:, **)
|
||||
# !channel.direct_message_channel?
|
||||
# end
|
||||
|
||||
# @!scope class
|
||||
# @!method contract(name = :default, class_name: self::Contract, default_values_from: nil)
|
||||
# @param name [Symbol] name for this contract
|
||||
# @param class_name [Class] a class defining the contract
|
||||
# @param default_values_from [Symbol] name of the model to get default values from
|
||||
# Checks the validity of the input parameters.
|
||||
# Implements ActiveModel::Validations and ActiveModel::Attributes.
|
||||
#
|
||||
# It stores the resulting contract in +context[:contract]+ by default
|
||||
# (can be customized by providing the +name+ argument).
|
||||
#
|
||||
# @example
|
||||
# contract
|
||||
#
|
||||
# class Contract
|
||||
# attribute :name
|
||||
# validates :name, presence: true
|
||||
# end
|
||||
|
||||
# @!scope class
|
||||
# @!method step(name)
|
||||
# @param name [Symbol] the name of this step
|
||||
# Runs arbitrary code. To mark a step as failed, a call to {#fail!} needs
|
||||
# to be made explicitly.
|
||||
#
|
||||
# @example
|
||||
# step :update_channel
|
||||
#
|
||||
# private
|
||||
#
|
||||
# def update_channel(channel:, params_to_edit:, **)
|
||||
# channel.update!(params_to_edit)
|
||||
# end
|
||||
# @example using {#fail!} in a step
|
||||
# step :save_channel
|
||||
#
|
||||
# private
|
||||
#
|
||||
# def save_channel(channel:, **)
|
||||
# fail!("something went wrong") unless channel.save
|
||||
# end
|
||||
|
||||
# @!scope class
|
||||
# @!method transaction(&block)
|
||||
# @param block [Proc] a block containing steps to be run inside a transaction
|
||||
# Runs steps inside a DB transaction.
|
||||
#
|
||||
# @example
|
||||
# transaction do
|
||||
# step :prevents_slug_collision
|
||||
# step :soft_delete_channel
|
||||
# step :log_channel_deletion
|
||||
# end
|
||||
|
||||
# @!visibility private
|
||||
def initialize(initial_context = {})
|
||||
@initial_context = initial_context.with_indifferent_access
|
||||
@context = Context.build(initial_context.merge(__steps__: self.class.steps))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def run
|
||||
run!
|
||||
rescue Failure => exception
|
||||
raise if context.object_id != exception.context.object_id
|
||||
end
|
||||
|
||||
def run!
|
||||
self.class.steps.each { |step| step.call(self, context) }
|
||||
end
|
||||
|
||||
def fail!(message)
|
||||
step_name = caller_locations(1, 1)[0].label
|
||||
context["result.step.#{step_name}"].fail(error: message)
|
||||
context.fail!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
66
plugins/chat/app/services/trash_channel.rb
Normal file
66
plugins/chat/app/services/trash_channel.rb
Normal file
@@ -0,0 +1,66 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
module Service
|
||||
# Service responsible for trashing a chat channel.
|
||||
# Note the slug is modified to prevent collisions.
|
||||
#
|
||||
# @example
|
||||
# Chat::Service::TrashChannel.call(channel_id: 2, guardian: guardian)
|
||||
#
|
||||
class TrashChannel
|
||||
include Base
|
||||
|
||||
# @!method call(channel_id:, guardian:)
|
||||
# @param [Integer] channel_id
|
||||
# @param [Guardian] guardian
|
||||
# @return [Chat::Service::Base::Context]
|
||||
|
||||
DELETE_CHANNEL_LOG_KEY = "chat_channel_delete"
|
||||
|
||||
model :channel, :fetch_channel
|
||||
policy :invalid_access
|
||||
transaction do
|
||||
step :prevents_slug_collision
|
||||
step :soft_delete_channel
|
||||
step :log_channel_deletion
|
||||
end
|
||||
step :enqueue_delete_channel_relations_job
|
||||
|
||||
private
|
||||
|
||||
def fetch_channel(channel_id:, **)
|
||||
ChatChannel.find_by(id: channel_id)
|
||||
end
|
||||
|
||||
def invalid_access(guardian:, channel:, **)
|
||||
guardian.can_preview_chat_channel?(channel) && guardian.can_delete_chat_channel?
|
||||
end
|
||||
|
||||
def prevents_slug_collision(channel:, **)
|
||||
channel.update!(
|
||||
slug:
|
||||
"#{Time.current.strftime("%Y%m%d-%H%M")}-#{channel.slug}-deleted".truncate(
|
||||
SiteSetting.max_topic_title_length,
|
||||
omission: "",
|
||||
),
|
||||
)
|
||||
end
|
||||
|
||||
def soft_delete_channel(guardian:, channel:, **)
|
||||
channel.trash!(guardian.user)
|
||||
end
|
||||
|
||||
def log_channel_deletion(guardian:, channel:, **)
|
||||
StaffActionLogger.new(guardian.user).log_custom(
|
||||
DELETE_CHANNEL_LOG_KEY,
|
||||
{ chat_channel_id: channel.id, chat_channel_name: channel.title(guardian.user) },
|
||||
)
|
||||
end
|
||||
|
||||
def enqueue_delete_channel_relations_job(channel:, **)
|
||||
Jobs.enqueue(:chat_channel_delete, chat_channel_id: channel.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
88
plugins/chat/app/services/update_channel.rb
Normal file
88
plugins/chat/app/services/update_channel.rb
Normal file
@@ -0,0 +1,88 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
module Service
|
||||
# Service responsible for updating a chat channel's name, slug, and description.
|
||||
#
|
||||
# For a CategoryChannel, the settings for auto_join_users and allow_channel_wide_mentions
|
||||
# are also editable.
|
||||
#
|
||||
# @example
|
||||
# Chat::Service::UpdateChannel.call(
|
||||
# channel_id: 2,
|
||||
# guardian: guardian,
|
||||
# name: "SuperChannel",
|
||||
# description: "This is the best channel",
|
||||
# slug: "super-channel",
|
||||
# )
|
||||
#
|
||||
class UpdateChannel
|
||||
include Base
|
||||
|
||||
# @!method call(channel_id:, guardian:, **params_to_edit)
|
||||
# @param [Integer] channel_id
|
||||
# @param [Guardian] guardian
|
||||
# @param [Hash] params_to_edit
|
||||
# @option params_to_edit [String,nil] name
|
||||
# @option params_to_edit [String,nil] description
|
||||
# @option params_to_edit [String,nil] slug
|
||||
# @option params_to_edit [Boolean] auto_join_users Only valid for {CategoryChannel}. Whether active users
|
||||
# with permission to see the category should automatically join the channel.
|
||||
# @option params_to_edit [Boolean] allow_channel_wide_mentions Allow the use of @here and @all in the channel.
|
||||
# @return [Chat::Service::Base::Context]
|
||||
|
||||
model :channel, :fetch_channel
|
||||
policy :no_direct_message_channel
|
||||
policy :check_channel_permission
|
||||
contract default_values_from: :channel
|
||||
step :update_channel
|
||||
step :publish_channel_update
|
||||
step :auto_join_users_if_needed
|
||||
|
||||
# @!visibility private
|
||||
class Contract
|
||||
attribute :name, :string
|
||||
attribute :description, :string
|
||||
attribute :slug, :string
|
||||
attribute :auto_join_users, :boolean, default: false
|
||||
attribute :allow_channel_wide_mentions, :boolean, default: true
|
||||
|
||||
before_validation do
|
||||
assign_attributes(
|
||||
attributes
|
||||
.symbolize_keys
|
||||
.slice(:name, :description, :slug)
|
||||
.transform_values(&:presence),
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_channel(channel_id:, **)
|
||||
ChatChannel.find_by(id: channel_id)
|
||||
end
|
||||
|
||||
def no_direct_message_channel(channel:, **)
|
||||
!channel.direct_message_channel?
|
||||
end
|
||||
|
||||
def check_channel_permission(guardian:, channel:, **)
|
||||
guardian.can_preview_chat_channel?(channel) && guardian.can_edit_chat_channel?
|
||||
end
|
||||
|
||||
def update_channel(channel:, contract:, **)
|
||||
channel.update!(contract.attributes)
|
||||
end
|
||||
|
||||
def publish_channel_update(channel:, guardian:, **)
|
||||
ChatPublisher.publish_chat_channel_edit(channel, guardian.user)
|
||||
end
|
||||
|
||||
def auto_join_users_if_needed(channel:, **)
|
||||
return unless channel.auto_join_users?
|
||||
Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_channel_memberships
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
46
plugins/chat/app/services/update_channel_status.rb
Normal file
46
plugins/chat/app/services/update_channel_status.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
module Service
|
||||
# Service responsible for updating a chat channel status.
|
||||
#
|
||||
# @example
|
||||
# Chat::Service::UpdateChannelStatus.call(channel_id: 2, guardian: guardian, status: "open")
|
||||
#
|
||||
class UpdateChannelStatus
|
||||
include Base
|
||||
|
||||
# @!method call(channel_id:, guardian:, status:)
|
||||
# @param [Integer] channel_id
|
||||
# @param [Guardian] guardian
|
||||
# @param [String] status
|
||||
# @return [Chat::Service::Base::Context]
|
||||
|
||||
model :channel, :fetch_channel
|
||||
contract
|
||||
policy :check_channel_permission
|
||||
step :change_status
|
||||
|
||||
# @!visibility private
|
||||
class Contract
|
||||
attribute :status
|
||||
validates :status, inclusion: { in: ChatChannel.editable_statuses.keys }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_channel(channel_id:, **)
|
||||
ChatChannel.find_by(id: channel_id)
|
||||
end
|
||||
|
||||
def check_channel_permission(guardian:, channel:, status:, **)
|
||||
guardian.can_preview_chat_channel?(channel) &&
|
||||
guardian.can_change_channel_status?(channel, status.to_sym)
|
||||
end
|
||||
|
||||
def change_status(channel:, status:, guardian:, **)
|
||||
channel.public_send("#{status}!", guardian.user)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
79
plugins/chat/app/services/update_user_last_read.rb
Normal file
79
plugins/chat/app/services/update_user_last_read.rb
Normal file
@@ -0,0 +1,79 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
module Service
|
||||
# Service responsible for updating the last read message id of a membership.
|
||||
#
|
||||
# @example
|
||||
# Chat::Service::UpdateUserLastRead.call(user_id: 1, channel_id: 2, message_id: 3, guardian: guardian)
|
||||
#
|
||||
class UpdateUserLastRead
|
||||
include Base
|
||||
|
||||
# @!method call(user_id:, channel_id:, message_id:, guardian:)
|
||||
# @param [Integer] user_id
|
||||
# @param [Integer] channel_id
|
||||
# @param [Integer] message_id
|
||||
# @param [Guardian] guardian
|
||||
# @return [Chat::Service::Base::Context]
|
||||
|
||||
model :membership, :fetch_active_membership
|
||||
policy :invalid_access
|
||||
contract
|
||||
policy :ensure_message_id_recency
|
||||
policy :ensure_message_exists
|
||||
step :update_last_read_message_id
|
||||
step :mark_associated_mentions_as_read
|
||||
step :publish_new_last_read_to_clients
|
||||
|
||||
# @!visibility private
|
||||
class Contract
|
||||
attribute :message_id, :integer
|
||||
attribute :user_id, :integer
|
||||
attribute :channel_id, :integer
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_active_membership(user_id:, channel_id:, **)
|
||||
UserChatChannelMembership.includes(:user, :chat_channel).find_by(
|
||||
user_id: user_id,
|
||||
chat_channel_id: channel_id,
|
||||
following: true,
|
||||
)
|
||||
end
|
||||
|
||||
def invalid_access(guardian:, membership:, **)
|
||||
guardian.can_join_chat_channel?(membership.chat_channel)
|
||||
end
|
||||
|
||||
def ensure_message_id_recency(message_id:, membership:, **)
|
||||
!membership.last_read_message_id || message_id >= membership.last_read_message_id
|
||||
end
|
||||
|
||||
def ensure_message_exists(channel_id:, message_id:, **)
|
||||
ChatMessage.with_deleted.exists?(chat_channel_id: channel_id, id: message_id)
|
||||
end
|
||||
|
||||
def update_last_read_message_id(message_id:, membership:, **)
|
||||
membership.update!(last_read_message_id: message_id)
|
||||
end
|
||||
|
||||
def mark_associated_mentions_as_read(membership:, message_id:, **)
|
||||
Notification
|
||||
.where(notification_type: Notification.types[:chat_mention])
|
||||
.where(user: membership.user)
|
||||
.where(read: false)
|
||||
.joins("INNER JOIN chat_mentions ON chat_mentions.notification_id = notifications.id")
|
||||
.joins("INNER JOIN chat_messages ON chat_mentions.chat_message_id = chat_messages.id")
|
||||
.where("chat_messages.id <= ?", message_id)
|
||||
.where("chat_messages.chat_channel_id = ?", membership.chat_channel.id)
|
||||
.update_all(read: true)
|
||||
end
|
||||
|
||||
def publish_new_last_read_to_clients(guardian:, channel_id:, message_id:, **)
|
||||
ChatPublisher.publish_user_tracking_state(guardian.user, channel_id, message_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user