mirror of
https://github.com/discourse/discourse.git
synced 2026-08-10 04:58:31 -05: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:
+3
-48
@@ -1,54 +1,9 @@
|
||||
:warning: This plugin is still in active development and may change frequently
|
||||
This plugin is still in active development and may change frequently
|
||||
|
||||
## Documentation
|
||||
|
||||
The Discourse Chat plugin adds chat functionality to your Discourse so it can natively support both long-form and short-form communication needs of your online community.
|
||||
|
||||
For documentation, see [Discourse Chat](https://meta.discourse.org/t/discourse-chat/230881)
|
||||
For user documentation, see [Discourse Chat](https://meta.discourse.org/t/discourse-chat/230881).
|
||||
|
||||
## Plugin API
|
||||
|
||||
### registerChatComposerButton
|
||||
|
||||
#### Usage
|
||||
|
||||
```javascript
|
||||
api.registerChatComposerButton({ id: "foo", ... });
|
||||
```
|
||||
|
||||
#### Options
|
||||
|
||||
Every option accepts a `value` or a `function`, when passing a function `this` will be the `chat-composer` component instance. Example of an option using a function:
|
||||
|
||||
```javascript
|
||||
api.registerChatComposerButton({
|
||||
id: "foo",
|
||||
displayed() {
|
||||
return this.site.mobileView && this.canAttachUploads;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
##### Required
|
||||
|
||||
- `id` unique, used to identify your button, eg: "gifs"
|
||||
- `action` callback when the button is pressed, can be an action name or an anonymous function, eg: "onFooClicked" or `() => { console.log("clicked") }`
|
||||
|
||||
A button requires at least an icon or a label:
|
||||
|
||||
- `icon`, eg: "times"
|
||||
- `label`, text displayed on the button, a translatable key, eg: "foo.bar"
|
||||
- `translatedLabel`, text displayed on the button, a string, eg: "Add gifs"
|
||||
|
||||
##### Optional
|
||||
|
||||
- `position`, can be "inline" or "dropdown", defaults to "inline"
|
||||
- `title`, title attribute of the button, a translatable key, eg: "foo.bar"
|
||||
- `translatedTitle`, title attribute of the button, a string, eg: "Add gifs"
|
||||
- `ariaLabel`, aria-label attribute of the button, a translatable key, eg: "foo.bar"
|
||||
- `translatedAriaLabel`, aria-label attribute of the button, a string, eg: "Add gifs"
|
||||
- `classNames`, additional names to add to the button’s class attribute, eg: ["foo", "bar"]
|
||||
- `displayed`, hide/or show the button, expects a boolean
|
||||
- `disabled`, sets the disabled attribute on the button, expects a boolean
|
||||
- `priority`, an integer defining the order of the buttons, higher comes first, eg: `700`
|
||||
- `dependentKeys`, list of property names which should trigger a refresh of the buttons when changed, eg: `["foo.bar", "bar.baz"]`
|
||||
For developer documentation, see [Discourse Documentation](https://discourse.github.io/discourse/).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,5 +1,3 @@
|
||||
/** @module Collection */
|
||||
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
@@ -7,19 +5,12 @@ import { Promise } from "rsvp";
|
||||
|
||||
/**
|
||||
* Handles a paginated API response.
|
||||
*
|
||||
* @class
|
||||
*/
|
||||
export default class Collection {
|
||||
@tracked items = [];
|
||||
@tracked meta = {};
|
||||
@tracked loading = false;
|
||||
|
||||
/**
|
||||
* Create a Collection instance
|
||||
* @param {string} resourceURL - the API endpoint to call
|
||||
* @param {callback} handler - anonymous function used to handle the response
|
||||
*/
|
||||
constructor(resourceURL, handler) {
|
||||
this._resourceURL = resourceURL;
|
||||
this._handler = handler;
|
||||
|
||||
@@ -5,6 +5,66 @@ import {
|
||||
} from "discourse/plugins/chat/discourse/components/chat-message";
|
||||
import { registerChatComposerButton } from "discourse/plugins/chat/discourse/lib/chat-composer-buttons";
|
||||
|
||||
/**
|
||||
* Class exposing the javascript API available to plugins and themes.
|
||||
* @class PluginApi
|
||||
*/
|
||||
|
||||
/**
|
||||
* Callback used to decorate a chat message
|
||||
*
|
||||
* @callback PluginApi~decorateChatMessageCallback
|
||||
* @param {ChatMessage} chatMessage - model
|
||||
* @param {HTMLElement} messageContainer - DOM node
|
||||
* @param {ChatChannel} chatChannel - model
|
||||
*/
|
||||
|
||||
/**
|
||||
* Decorate a chat message
|
||||
*
|
||||
* @memberof PluginApi
|
||||
* @instance
|
||||
* @function decorateChatMessage
|
||||
* @param {PluginApi~decorateChatMessageCallback} decorator
|
||||
* @example
|
||||
*
|
||||
* api.decorateChatMessage((chatMessage, messageContainer) => {
|
||||
* messageContainer.dataset.foo = chatMessage.id;
|
||||
* });
|
||||
*/
|
||||
|
||||
/**
|
||||
* Register a button in the chat composer
|
||||
*
|
||||
* @memberof PluginApi
|
||||
* @instance
|
||||
* @function registerChatComposerButton
|
||||
* @param {Object} options
|
||||
* @param {number} options.id - The id of the button
|
||||
* @param {function} options.action - An action name or an anonymous function called when the button is pressed, eg: "onFooClicked" or `() => { console.log("clicked") }`
|
||||
* @param {string} options.icon - A valid font awesome icon name, eg: "far fa-image"
|
||||
* @param {string} options.label - Text displayed on the button, a translatable key, eg: "foo.bar"
|
||||
* @param {string} options.translatedLabel - Text displayed on the button, a string, eg: "Add gifs"
|
||||
* @param {string} [options.position] - Can be "inline" or "dropdown", defaults to "inline"
|
||||
* @param {string} [options.title] - Title attribute of the button, a translatable key, eg: "foo.bar"
|
||||
* @param {string} [options.translatedTitle] - Title attribute of the button, a string, eg: "Add gifs"
|
||||
* @param {string} [options.ariaLabel] - aria-label attribute of the button, a translatable key, eg: "foo.bar"
|
||||
* @param {string} [options.translatedAriaLabel] - aria-label attribute of the button, a string, eg: "Add gifs"
|
||||
* @param {string} [options.classNames] - Additional names to add to the button’s class attribute, eg: ["foo", "bar"]
|
||||
* @param {boolean} [options.displayed] - Hide or show the button
|
||||
* @param {boolean} [options.disabled] - Sets the disabled attribute on the button
|
||||
* @param {number} [options.priority] - An integer defining the order of the buttons, higher comes first, eg: `700`
|
||||
* @param {Array.<string>} [options.dependentKeys] - List of property names which should trigger a refresh of the buttons when changed, eg: `["foo.bar", "bar.baz"]`
|
||||
* @example
|
||||
*
|
||||
* api.registerChatComposerButton({
|
||||
* id: "foo",
|
||||
* displayed() {
|
||||
* return this.site.mobileView && this.canAttachUploads;
|
||||
* }
|
||||
* });
|
||||
*/
|
||||
|
||||
export default {
|
||||
name: "chat-plugin-api",
|
||||
after: "inject-discourse-objects",
|
||||
|
||||
@@ -14,7 +14,8 @@ export default function withChatChannel(extendedClass) {
|
||||
this.controllerFor("chat-channel").set("targetMessageId", null);
|
||||
this.chat.activeChannel = model;
|
||||
|
||||
let { messageId } = this.paramsFor(this.routeName);
|
||||
let { messageId, channelTitle } = this.paramsFor(this.routeName);
|
||||
|
||||
// messageId query param backwards-compatibility
|
||||
if (messageId) {
|
||||
this.router.replaceWith(
|
||||
@@ -24,7 +25,6 @@ export default function withChatChannel(extendedClass) {
|
||||
);
|
||||
}
|
||||
|
||||
const { channelTitle } = this.paramsFor("chat.channel");
|
||||
if (channelTitle && channelTitle !== model.slugifiedTitle) {
|
||||
const nearMessageParams = this.paramsFor("chat.channel.near-message");
|
||||
if (nearMessageParams.messageId) {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/** @module ChatApi */
|
||||
|
||||
import Service, { inject as service } from "@ember/service";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import UserChatChannelMembership from "discourse/plugins/chat/discourse/models/user-chat-channel-membership";
|
||||
@@ -8,7 +6,7 @@ import Collection from "../lib/collection";
|
||||
/**
|
||||
* Chat API service. Provides methods to interact with the chat API.
|
||||
*
|
||||
* @class
|
||||
* @module ChatApi
|
||||
* @implements {@ember/service}
|
||||
*/
|
||||
export default class ChatApi extends Service {
|
||||
@@ -31,7 +29,7 @@ export default class ChatApi extends Service {
|
||||
|
||||
/**
|
||||
* List all accessible category channels of the current user.
|
||||
* @returns {module:Collection}
|
||||
* @returns {Collection}
|
||||
*
|
||||
* @example
|
||||
*
|
||||
@@ -70,17 +68,14 @@ export default class ChatApi extends Service {
|
||||
/**
|
||||
* Destroys a channel.
|
||||
* @param {number} channelId - The ID of the channel.
|
||||
* @param {string} channelName - The name of the channel to be destroyed, used as confirmation.
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* this.chatApi.destroyChannel(1, "foo").then(() => { ... })
|
||||
* this.chatApi.destroyChannel(1).then(() => { ... })
|
||||
*/
|
||||
destroyChannel(channelId, channelName) {
|
||||
return this.#deleteRequest(`/channels/${channelId}`, {
|
||||
channel: { name_confirmation: channelName },
|
||||
});
|
||||
destroyChannel(channelId) {
|
||||
return this.#deleteRequest(`/channels/${channelId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,7 +169,7 @@ export default class ChatApi extends Service {
|
||||
/**
|
||||
* Lists members of a channel.
|
||||
* @param {number} channelId - The ID of the channel.
|
||||
* @returns {module:Collection}
|
||||
* @returns {Collection}
|
||||
*/
|
||||
listChannelMemberships(channelId) {
|
||||
return new Collection(
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
## Modules
|
||||
|
||||
<dl>
|
||||
<dt><a href="#module_Collection">Collection</a></dt>
|
||||
<dd></dd>
|
||||
<dt><a href="#module_ChatApi">ChatApi</a></dt>
|
||||
<dd></dd>
|
||||
</dl>
|
||||
|
||||
<a name="module_Collection"></a>
|
||||
|
||||
## Collection
|
||||
|
||||
* [Collection](#module_Collection)
|
||||
* [module.exports](#exp_module_Collection--module.exports) ⏏
|
||||
* [new module.exports(resourceURL, handler)](#new_module_Collection--module.exports_new)
|
||||
* [.load()](#module_Collection--module.exports+load) ⇒ <code>Promise</code>
|
||||
* [.loadMore()](#module_Collection--module.exports+loadMore) ⇒ <code>Promise</code>
|
||||
|
||||
|
||||
* * *
|
||||
|
||||
<a name="exp_module_Collection--module.exports"></a>
|
||||
|
||||
### module.exports ⏏
|
||||
Handles a paginated API response.
|
||||
|
||||
**Kind**: Exported class
|
||||
|
||||
* * *
|
||||
|
||||
<a name="new_module_Collection--module.exports_new"></a>
|
||||
|
||||
#### new module.exports(resourceURL, handler)
|
||||
Create a Collection instance
|
||||
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| resourceURL | <code>string</code> | the API endpoint to call |
|
||||
| handler | <code>callback</code> | anonymous function used to handle the response |
|
||||
|
||||
|
||||
* * *
|
||||
|
||||
<a name="module_Collection--module.exports+load"></a>
|
||||
|
||||
#### module.exports.load() ⇒ <code>Promise</code>
|
||||
Loads first batch of results
|
||||
|
||||
**Kind**: instance method of [<code>module.exports</code>](#exp_module_Collection--module.exports)
|
||||
|
||||
* * *
|
||||
|
||||
<a name="module_Collection--module.exports+loadMore"></a>
|
||||
|
||||
#### module.exports.loadMore() ⇒ <code>Promise</code>
|
||||
Attempts to load more results
|
||||
|
||||
**Kind**: instance method of [<code>module.exports</code>](#exp_module_Collection--module.exports)
|
||||
|
||||
* * *
|
||||
|
||||
<a name="module_ChatApi"></a>
|
||||
|
||||
## ChatApi
|
||||
|
||||
* [ChatApi](#module_ChatApi)
|
||||
* [module.exports](#exp_module_ChatApi--module.exports) ⏏
|
||||
* [.channel(channelId)](#module_ChatApi--module.exports+channel) ⇒ <code>Promise</code>
|
||||
* [.channels()](#module_ChatApi--module.exports+channels) ⇒ [<code>module.exports</code>](#exp_module_Collection--module.exports)
|
||||
* [.moveChannelMessages(channelId, data)](#module_ChatApi--module.exports+moveChannelMessages) ⇒ <code>Promise</code>
|
||||
* [.destroyChannel(channelId, channelName)](#module_ChatApi--module.exports+destroyChannel) ⇒ <code>Promise</code>
|
||||
* [.createChannel(data)](#module_ChatApi--module.exports+createChannel) ⇒ <code>Promise</code>
|
||||
* [.categoryPermissions(categoryId)](#module_ChatApi--module.exports+categoryPermissions) ⇒ <code>Promise</code>
|
||||
* [.sendMessage(channelId, data)](#module_ChatApi--module.exports+sendMessage) ⇒ <code>Promise</code>
|
||||
* [.createChannelArchive(channelId, data)](#module_ChatApi--module.exports+createChannelArchive) ⇒ <code>Promise</code>
|
||||
* [.updateChannel(channelId, data)](#module_ChatApi--module.exports+updateChannel) ⇒ <code>Promise</code>
|
||||
* [.updateChannelStatus(channelId, status)](#module_ChatApi--module.exports+updateChannelStatus) ⇒ <code>Promise</code>
|
||||
* [.listChannelMemberships(channelId)](#module_ChatApi--module.exports+listChannelMemberships) ⇒ [<code>module.exports</code>](#exp_module_Collection--module.exports)
|
||||
* [.listCurrentUserChannels()](#module_ChatApi--module.exports+listCurrentUserChannels) ⇒ <code>Promise</code>
|
||||
* [.followChannel(channelId)](#module_ChatApi--module.exports+followChannel) ⇒ <code>Promise</code>
|
||||
* [.unfollowChannel(channelId)](#module_ChatApi--module.exports+unfollowChannel) ⇒ <code>Promise</code>
|
||||
* [.updateCurrentUserChannelNotificationsSettings(channelId, data)](#module_ChatApi--module.exports+updateCurrentUserChannelNotificationsSettings) ⇒ <code>Promise</code>
|
||||
|
||||
|
||||
* * *
|
||||
|
||||
<a name="exp_module_ChatApi--module.exports"></a>
|
||||
|
||||
### module.exports ⏏
|
||||
Chat API service. Provides methods to interact with the chat API.
|
||||
|
||||
**Kind**: Exported class
|
||||
**Implements**: <code>{@ember/service}</code>
|
||||
|
||||
* * *
|
||||
|
||||
<a name="module_ChatApi--module.exports+channel"></a>
|
||||
|
||||
#### module.exports.channel(channelId) ⇒ <code>Promise</code>
|
||||
Get a channel by its ID.
|
||||
|
||||
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| channelId | <code>number</code> | The ID of the channel. |
|
||||
|
||||
**Example**
|
||||
```js
|
||||
this.chatApi.channel(1).then(channel => { ... })
|
||||
```
|
||||
|
||||
* * *
|
||||
|
||||
<a name="module_ChatApi--module.exports+channels"></a>
|
||||
|
||||
#### module.exports.channels() ⇒ [<code>module.exports</code>](#exp_module_Collection--module.exports)
|
||||
List all accessible category channels of the current user.
|
||||
|
||||
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
|
||||
**Example**
|
||||
```js
|
||||
this.chatApi.channels.then(channels => { ... })
|
||||
```
|
||||
|
||||
* * *
|
||||
|
||||
<a name="module_ChatApi--module.exports+moveChannelMessages"></a>
|
||||
|
||||
#### module.exports.moveChannelMessages(channelId, data) ⇒ <code>Promise</code>
|
||||
Moves messages from one channel to another.
|
||||
|
||||
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| channelId | <code>number</code> | The ID of the original channel. |
|
||||
| data | <code>object</code> | Params of the move. |
|
||||
| data.message_ids | <code>Array.<number></code> | IDs of the moved messages. |
|
||||
| data.destination_channel_id | <code>number</code> | ID of the channel where the messages are moved to. |
|
||||
|
||||
**Example**
|
||||
```js
|
||||
this.chatApi
|
||||
.moveChannelMessages(1, {
|
||||
message_ids: [2, 3],
|
||||
destination_channel_id: 4,
|
||||
}).then(() => { ... })
|
||||
```
|
||||
|
||||
* * *
|
||||
|
||||
<a name="module_ChatApi--module.exports+destroyChannel"></a>
|
||||
|
||||
#### module.exports.destroyChannel(channelId, channelName) ⇒ <code>Promise</code>
|
||||
Destroys a channel.
|
||||
|
||||
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| channelId | <code>number</code> | The ID of the channel. |
|
||||
| channelName | <code>string</code> | The name of the channel to be destroyed, used as confirmation. |
|
||||
|
||||
**Example**
|
||||
```js
|
||||
this.chatApi.destroyChannel(1, "foo").then(() => { ... })
|
||||
```
|
||||
|
||||
* * *
|
||||
|
||||
<a name="module_ChatApi--module.exports+createChannel"></a>
|
||||
|
||||
#### module.exports.createChannel(data) ⇒ <code>Promise</code>
|
||||
Creates a channel.
|
||||
|
||||
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| data | <code>object</code> | Params of the channel. |
|
||||
| data.name | <code>string</code> | The name of the channel. |
|
||||
| data.chatable_id | <code>string</code> | The category of the channel. |
|
||||
| data.description | <code>string</code> | The description of the channel. |
|
||||
| [data.auto_join_users] | <code>boolean</code> | Should users join this channel automatically. |
|
||||
|
||||
**Example**
|
||||
```js
|
||||
this.chatApi
|
||||
.createChannel({ name: "foo", chatable_id: 1, description "bar" })
|
||||
.then((channel) => { ... })
|
||||
```
|
||||
|
||||
* * *
|
||||
|
||||
<a name="module_ChatApi--module.exports+categoryPermissions"></a>
|
||||
|
||||
#### module.exports.categoryPermissions(categoryId) ⇒ <code>Promise</code>
|
||||
Lists chat permissions for a category.
|
||||
|
||||
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| categoryId | <code>number</code> | ID of the category. |
|
||||
|
||||
|
||||
* * *
|
||||
|
||||
<a name="module_ChatApi--module.exports+sendMessage"></a>
|
||||
|
||||
#### module.exports.sendMessage(channelId, data) ⇒ <code>Promise</code>
|
||||
Sends a message.
|
||||
|
||||
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| channelId | <code>number</code> | ID of the channel. |
|
||||
| data | <code>object</code> | Params of the message. |
|
||||
| data.message | <code>string</code> | The raw content of the message in markdown. |
|
||||
| data.cooked | <code>string</code> | The cooked content of the message. |
|
||||
| [data.in_reply_to_id] | <code>number</code> | The ID of the replied-to message. |
|
||||
| [data.staged_id] | <code>number</code> | The staged ID of the message before it was persisted. |
|
||||
| [data.upload_ids] | <code>Array.<number></code> | Array of upload ids linked to the message. |
|
||||
|
||||
|
||||
* * *
|
||||
|
||||
<a name="module_ChatApi--module.exports+createChannelArchive"></a>
|
||||
|
||||
#### module.exports.createChannelArchive(channelId, data) ⇒ <code>Promise</code>
|
||||
Creates a channel archive.
|
||||
|
||||
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| channelId | <code>number</code> | The ID of the channel. |
|
||||
| data | <code>object</code> | Params of the archive. |
|
||||
| data.selection | <code>string</code> | "new_topic" or "existing_topic". |
|
||||
| [data.title] | <code>string</code> | Title of the topic when creating a new topic. |
|
||||
| [data.category_id] | <code>string</code> | ID of the category used when creating a new topic. |
|
||||
| [data.tags] | <code>Array.<string></code> | tags used when creating a new topic. |
|
||||
| [data.topic_id] | <code>string</code> | ID of the topic when using an existing topic. |
|
||||
|
||||
|
||||
* * *
|
||||
|
||||
<a name="module_ChatApi--module.exports+updateChannel"></a>
|
||||
|
||||
#### module.exports.updateChannel(channelId, data) ⇒ <code>Promise</code>
|
||||
Updates a channel.
|
||||
|
||||
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| channelId | <code>number</code> | The ID of the channel. |
|
||||
| data | <code>object</code> | Params of the archive. |
|
||||
| [data.description] | <code>string</code> | Description of the channel. |
|
||||
| [data.name] | <code>string</code> | Name of the channel. |
|
||||
|
||||
|
||||
* * *
|
||||
|
||||
<a name="module_ChatApi--module.exports+updateChannelStatus"></a>
|
||||
|
||||
#### module.exports.updateChannelStatus(channelId, status) ⇒ <code>Promise</code>
|
||||
Updates the status of a channel.
|
||||
|
||||
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| channelId | <code>number</code> | The ID of the channel. |
|
||||
| status | <code>string</code> | The new status, can be "open" or "closed". |
|
||||
|
||||
|
||||
* * *
|
||||
|
||||
<a name="module_ChatApi--module.exports+listChannelMemberships"></a>
|
||||
|
||||
#### module.exports.listChannelMemberships(channelId) ⇒ [<code>module.exports</code>](#exp_module_Collection--module.exports)
|
||||
Lists members of a channel.
|
||||
|
||||
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| channelId | <code>number</code> | The ID of the channel. |
|
||||
|
||||
|
||||
* * *
|
||||
|
||||
<a name="module_ChatApi--module.exports+listCurrentUserChannels"></a>
|
||||
|
||||
#### module.exports.listCurrentUserChannels() ⇒ <code>Promise</code>
|
||||
Lists public and direct message channels of the current user.
|
||||
|
||||
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
|
||||
|
||||
* * *
|
||||
|
||||
<a name="module_ChatApi--module.exports+followChannel"></a>
|
||||
|
||||
#### module.exports.followChannel(channelId) ⇒ <code>Promise</code>
|
||||
Makes current user follow a channel.
|
||||
|
||||
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| channelId | <code>number</code> | The ID of the channel. |
|
||||
|
||||
|
||||
* * *
|
||||
|
||||
<a name="module_ChatApi--module.exports+unfollowChannel"></a>
|
||||
|
||||
#### module.exports.unfollowChannel(channelId) ⇒ <code>Promise</code>
|
||||
Makes current user unfollow a channel.
|
||||
|
||||
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| channelId | <code>number</code> | The ID of the channel. |
|
||||
|
||||
|
||||
* * *
|
||||
|
||||
<a name="module_ChatApi--module.exports+updateCurrentUserChannelNotificationsSettings"></a>
|
||||
|
||||
#### module.exports.updateCurrentUserChannelNotificationsSettings(channelId, data) ⇒ <code>Promise</code>
|
||||
Update notifications settings of current user for a channel.
|
||||
|
||||
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| channelId | <code>number</code> | The ID of the channel. |
|
||||
| data | <code>object</code> | The settings to modify. |
|
||||
| [data.muted] | <code>boolean</code> | Mutes the channel. |
|
||||
| [data.desktop_notification_level] | <code>string</code> | Notifications level on desktop: never, mention or always. |
|
||||
| [data.mobile_notification_level] | <code>string</code> | Notifications level on mobile: never, mention or always. |
|
||||
|
||||
|
||||
* * *
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# frozen_string_literal: true
|
||||
#
|
||||
# = Chat::Endpoint
|
||||
#
|
||||
# This class is to be used via its helper +with_service+ in a controller. Its
|
||||
# main purpose is to ease how actions can be run upon a service completion.
|
||||
# Since a service will likely return the same kind of things over and over,
|
||||
# this allows us to not have to repeat the same boilerplate code in every
|
||||
# controller.
|
||||
#
|
||||
# There are several available actions and we can add new ones very easily:
|
||||
#
|
||||
# * +on_success+: will execute the provided block if the service succeeds
|
||||
# * +on_failure+: will execute the provided block if the service fails
|
||||
# * +on_failed_policy(name)+: will execute the provided block if the policy
|
||||
# named `name` fails
|
||||
# * +on_failed_contract(name)+: will execute the provided block if the contract
|
||||
# named `name` fails
|
||||
# * +on_model_not_found(name)+: will execute the provided block if the service
|
||||
# fails and its model is not present
|
||||
#
|
||||
# @example
|
||||
# # in a controller
|
||||
# def create
|
||||
# with_service MyService do
|
||||
# on_success do
|
||||
# flash[:notice] = "Success!"
|
||||
# redirect_to a_path
|
||||
# end
|
||||
# on_failed_policy(:a_named_policy) { redirect_to root_path }
|
||||
# on_failure { render :new }
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# The actions will be evaluated in the order they appear. So even if the
|
||||
# service will ultimately fail with a failed policy, in this example only the
|
||||
# +on_failed_policy+ action will be executed and not the +on_failure+ one.
|
||||
# The only exception to this being +on_failure+ as it will always be executed
|
||||
# last.
|
||||
#
|
||||
class Chat::Endpoint
|
||||
# @!visibility private
|
||||
NULL_RESULT = OpenStruct.new(failure?: false)
|
||||
# @!visibility private
|
||||
AVAILABLE_ACTIONS = {
|
||||
on_success: -> { result.success? },
|
||||
on_failure: -> { result.failure? },
|
||||
on_failed_policy: ->(name = "default") { failure_for?("result.policy.#{name}") },
|
||||
on_failed_contract: ->(name = "default") { failure_for?("result.contract.#{name}") },
|
||||
on_model_not_found: ->(name = "model") { failure_for?("result.model.#{name}") },
|
||||
}.with_indifferent_access.freeze
|
||||
|
||||
# @!visibility private
|
||||
attr_reader :service, :controller, :dependencies
|
||||
|
||||
delegate :result, to: :controller
|
||||
|
||||
# @!visibility private
|
||||
def initialize(service, controller, **dependencies)
|
||||
@service = service
|
||||
@controller = controller
|
||||
@dependencies = dependencies
|
||||
@actions = {}
|
||||
end
|
||||
|
||||
# @param service [Class] a class including {Chat::Service::Base}
|
||||
# @param block [Proc] a block containing the steps to match on
|
||||
# @return [void]
|
||||
def self.call(service, controller, **dependencies, &block)
|
||||
new(service, controller, **dependencies).call(&block)
|
||||
end
|
||||
|
||||
# @!visibility private
|
||||
def call(&block)
|
||||
instance_eval(&block)
|
||||
controller.run_service(service, dependencies)
|
||||
# Always have `on_failure` as the last action
|
||||
(
|
||||
actions
|
||||
.except(:on_failure)
|
||||
.merge(actions.slice(:on_failure))
|
||||
.detect { |name, (condition, _)| condition.call } || [-> {}]
|
||||
).flatten.last.call
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :actions
|
||||
|
||||
def failure_for?(key)
|
||||
(controller.result[key] || NULL_RESULT).failure?
|
||||
end
|
||||
|
||||
def add_action(name, *args, &block)
|
||||
actions[[name, *args].join("_").to_sym] = [
|
||||
-> { instance_exec(*args, &AVAILABLE_ACTIONS[name]) },
|
||||
-> { controller.instance_eval(&block) },
|
||||
]
|
||||
end
|
||||
|
||||
def method_missing(method_name, *args, &block)
|
||||
return super unless AVAILABLE_ACTIONS[method_name]
|
||||
add_action(method_name, *args, &block)
|
||||
end
|
||||
|
||||
def respond_to_missing?(method_name, include_private = false)
|
||||
AVAILABLE_ACTIONS[method_name] || super
|
||||
end
|
||||
end
|
||||
@@ -61,6 +61,7 @@ module Chat::GuardianExtensions
|
||||
return false if chat_channel.status.to_sym == target_status.to_sym
|
||||
return false if !is_staff?
|
||||
|
||||
# FIXME: This logic shouldn't be handled in guardian
|
||||
case target_status
|
||||
when :closed
|
||||
chat_channel.open?
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
# = Chat::StepsInspector
|
||||
#
|
||||
# This class takes a {Chat::Service::Base::Context} object and inspects it.
|
||||
# It will output a list of steps and what is their known state.
|
||||
class StepsInspector
|
||||
# @!visibility private
|
||||
class Step
|
||||
attr_reader :step, :result, :nesting_level
|
||||
|
||||
delegate :name, to: :step
|
||||
delegate :failure?, :success?, :error, to: :step_result, allow_nil: true
|
||||
|
||||
def self.for(step, result, nesting_level: 0)
|
||||
class_name =
|
||||
"#{module_parent_name}::#{step.class.name.split("::").last.sub(/^(\w+)Step$/, "\\1")}"
|
||||
class_name.constantize.new(step, result, nesting_level: nesting_level)
|
||||
end
|
||||
|
||||
def initialize(step, result, nesting_level: 0)
|
||||
@step = step
|
||||
@result = result
|
||||
@nesting_level = nesting_level
|
||||
end
|
||||
|
||||
def type
|
||||
self.class.name.split("::").last.downcase
|
||||
end
|
||||
|
||||
def emoji
|
||||
return "❌" if failure?
|
||||
return "✅" if success?
|
||||
""
|
||||
end
|
||||
|
||||
def steps
|
||||
[self]
|
||||
end
|
||||
|
||||
def inspect
|
||||
"#{" " * nesting_level}[#{type}] '#{name}' #{emoji}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def step_result
|
||||
result["result.#{type}.#{name}"]
|
||||
end
|
||||
end
|
||||
|
||||
# @!visibility private
|
||||
class Model < Step
|
||||
def error
|
||||
step_result.exception.full_message
|
||||
end
|
||||
end
|
||||
|
||||
# @!visibility private
|
||||
class Contract < Step
|
||||
def error
|
||||
step_result.errors.inspect
|
||||
end
|
||||
end
|
||||
|
||||
# @!visibility private
|
||||
class Policy < Step
|
||||
end
|
||||
|
||||
# @!visibility private
|
||||
class Transaction < Step
|
||||
def steps
|
||||
[self, *step.steps.map { Step.for(_1, result, nesting_level: nesting_level + 1).steps }]
|
||||
end
|
||||
|
||||
def inspect
|
||||
"#{" " * nesting_level}[#{type}]"
|
||||
end
|
||||
|
||||
def step_result
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
attr_reader :steps, :result
|
||||
|
||||
def initialize(result)
|
||||
@steps = result.__steps__.map { Step.for(_1, result).steps }.flatten
|
||||
@result = result
|
||||
end
|
||||
|
||||
# Inspect the provided result object.
|
||||
# Example output:
|
||||
# [1/4] [model] 'channel' ✅
|
||||
# [2/4] [contract] 'default' ✅
|
||||
# [3/4] [policy] 'check_channel_permission' ❌
|
||||
# [4/4] [step] 'change_status'
|
||||
# @return [String] the steps of the result object with their state
|
||||
def inspect
|
||||
steps
|
||||
.map
|
||||
.with_index { |step, index| "[#{index + 1}/#{steps.size}] #{step.inspect}" }
|
||||
.join("\n")
|
||||
end
|
||||
|
||||
# @return [String, nil] the first available error, if any.
|
||||
def error
|
||||
steps.detect(&:failure?)&.error
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,13 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
task "chat:doc" do
|
||||
destination = File.join(Rails.root, "plugins/chat/docs/FRONTEND.md")
|
||||
config = File.join(Rails.root, ".jsdoc")
|
||||
|
||||
files = %w[
|
||||
plugins/chat/assets/javascripts/discourse/lib/collection.js
|
||||
plugins/chat/assets/javascripts/discourse/services/chat-api.js
|
||||
]
|
||||
|
||||
`yarn --silent jsdoc2md --separators -c #{config} -f #{files.join(" ")} > #{destination}`
|
||||
end
|
||||
@@ -91,6 +91,7 @@ require_relative "app/core_ext/plugin_instance.rb"
|
||||
GlobalSetting.add_default(:allow_unsecure_chat_uploads, false)
|
||||
|
||||
after_initialize do
|
||||
# Namespace for classes and modules parts of chat plugin
|
||||
module ::Chat
|
||||
PLUGIN_NAME = "chat"
|
||||
HAS_CHAT_ENABLED = "has_chat_enabled"
|
||||
@@ -119,6 +120,7 @@ after_initialize do
|
||||
"../app/controllers/admin/admin_incoming_chat_webhooks_controller.rb",
|
||||
__FILE__,
|
||||
)
|
||||
load File.expand_path("../app/helpers/with_service_helper.rb", __FILE__)
|
||||
load File.expand_path("../app/controllers/chat_base_controller.rb", __FILE__)
|
||||
load File.expand_path("../app/controllers/chat_controller.rb", __FILE__)
|
||||
load File.expand_path("../app/controllers/emojis_controller.rb", __FILE__)
|
||||
@@ -163,6 +165,7 @@ after_initialize do
|
||||
load File.expand_path("../app/serializers/admin_chat_index_serializer.rb", __FILE__)
|
||||
load File.expand_path("../app/serializers/user_chat_message_bookmark_serializer.rb", __FILE__)
|
||||
load File.expand_path("../app/serializers/reviewable_chat_message_serializer.rb", __FILE__)
|
||||
load File.expand_path("../app/services/base.rb", __FILE__)
|
||||
load File.expand_path("../lib/chat_channel_fetcher.rb", __FILE__)
|
||||
load File.expand_path("../lib/chat_channel_hashtag_data_source.rb", __FILE__)
|
||||
load File.expand_path("../lib/chat_mailer.rb", __FILE__)
|
||||
@@ -191,6 +194,8 @@ after_initialize do
|
||||
load File.expand_path("../lib/slack_compatibility.rb", __FILE__)
|
||||
load File.expand_path("../lib/post_notification_handler.rb", __FILE__)
|
||||
load File.expand_path("../lib/secure_uploads_compatibility.rb", __FILE__)
|
||||
load File.expand_path("../lib/endpoint.rb", __FILE__)
|
||||
load File.expand_path("../lib/steps_inspector.rb", __FILE__)
|
||||
load File.expand_path("../app/jobs/regular/auto_manage_channel_memberships.rb", __FILE__)
|
||||
load File.expand_path("../app/jobs/regular/auto_join_channel_batch.rb", __FILE__)
|
||||
load File.expand_path("../app/jobs/regular/process_chat_message.rb", __FILE__)
|
||||
@@ -207,7 +212,11 @@ after_initialize do
|
||||
load File.expand_path("../app/jobs/scheduled/auto_join_users.rb", __FILE__)
|
||||
load File.expand_path("../app/jobs/scheduled/chat_periodical_updates.rb", __FILE__)
|
||||
load File.expand_path("../app/services/chat_publisher.rb", __FILE__)
|
||||
load File.expand_path("../app/services/trash_channel.rb", __FILE__)
|
||||
load File.expand_path("../app/services/update_channel.rb", __FILE__)
|
||||
load File.expand_path("../app/services/update_channel_status.rb", __FILE__)
|
||||
load File.expand_path("../app/services/chat_message_destroyer.rb", __FILE__)
|
||||
load File.expand_path("../app/services/update_user_last_read.rb", __FILE__)
|
||||
load File.expand_path("../app/controllers/api_controller.rb", __FILE__)
|
||||
load File.expand_path("../app/controllers/api/chat_channels_controller.rb", __FILE__)
|
||||
load File.expand_path("../app/controllers/api/chat_current_user_channels_controller.rb", __FILE__)
|
||||
|
||||
@@ -59,9 +59,50 @@ Fabricator(:chat_message) do
|
||||
end
|
||||
|
||||
Fabricator(:chat_mention) do
|
||||
chat_message { Fabricate(:chat_message) }
|
||||
transient read: false
|
||||
transient high_priority: true
|
||||
transient identifier: :direct_mentions
|
||||
|
||||
user { Fabricate(:user) }
|
||||
notification { Fabricate(:notification) }
|
||||
chat_message { Fabricate(:chat_message) }
|
||||
notification do |attrs|
|
||||
# All this setup should be in a service we could just call here
|
||||
# At the moment the logic is all split in a job
|
||||
channel = attrs[:chat_message].chat_channel
|
||||
|
||||
payload = {
|
||||
is_direct_message_channel: channel.direct_message_channel?,
|
||||
mentioned_by_username: attrs[:chat_message].user.username,
|
||||
chat_channel_id: channel.id,
|
||||
chat_message_id: attrs[:chat_message].id,
|
||||
}
|
||||
|
||||
if channel.direct_message_channel?
|
||||
payload[:chat_channel_title] = channel.title(membership.user)
|
||||
payload[:chat_channel_slug] = channel.slug
|
||||
end
|
||||
|
||||
unless attrs[:identifier] == :direct_mentions
|
||||
case attrs[:identifier]
|
||||
when :here_mentions
|
||||
payload[:identifier] = "here"
|
||||
when :global_mentions
|
||||
payload[:identifier] = "all"
|
||||
else
|
||||
payload[:identifier] = attrs[:identifier] if attrs[:identifier]
|
||||
payload[:is_group_mention] = true
|
||||
end
|
||||
end
|
||||
|
||||
Fabricate(
|
||||
:notification,
|
||||
notification_type: Notification.types[:chat_mention],
|
||||
user: attrs[:user],
|
||||
data: payload.to_json,
|
||||
read: attrs[:read],
|
||||
high_priority: attrs[:high_priority],
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
Fabricator(:chat_message_reaction) do
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe Chat::Endpoint do
|
||||
class SuccessService
|
||||
include Chat::Service::Base
|
||||
end
|
||||
|
||||
class FailureService
|
||||
include Chat::Service::Base
|
||||
|
||||
step :fail_step
|
||||
|
||||
def fail_step
|
||||
fail!("error")
|
||||
end
|
||||
end
|
||||
|
||||
class FailedPolicyService
|
||||
include Chat::Service::Base
|
||||
|
||||
policy :test
|
||||
|
||||
def test
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
class SuccessPolicyService
|
||||
include Chat::Service::Base
|
||||
|
||||
policy :test
|
||||
|
||||
def test
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
class FailedContractService
|
||||
include Chat::Service::Base
|
||||
|
||||
class Contract
|
||||
attribute :test
|
||||
validates :test, presence: true
|
||||
end
|
||||
|
||||
contract
|
||||
end
|
||||
|
||||
class SuccessContractService
|
||||
include Chat::Service::Base
|
||||
|
||||
contract
|
||||
end
|
||||
|
||||
class FailureWithModelService
|
||||
include Chat::Service::Base
|
||||
|
||||
model :fake_model, :fetch_fake_model
|
||||
|
||||
private
|
||||
|
||||
def fetch_fake_model
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
class SuccessWithModelService
|
||||
include Chat::Service::Base
|
||||
|
||||
model :fake_model, :fetch_fake_model
|
||||
|
||||
private
|
||||
|
||||
def fetch_fake_model
|
||||
:model_found
|
||||
end
|
||||
end
|
||||
|
||||
describe ".call(service, &block)" do
|
||||
subject(:endpoint) { described_class.call(service, controller, &actions_block) }
|
||||
|
||||
let(:result) { controller.result }
|
||||
let(:actions_block) { controller.instance_eval(actions) }
|
||||
let(:service) { SuccessService }
|
||||
let(:actions) { "proc {}" }
|
||||
let(:controller) do
|
||||
Class
|
||||
.new(Chat::Api) do
|
||||
def request
|
||||
OpenStruct.new
|
||||
end
|
||||
|
||||
def params
|
||||
ActionController::Parameters.new
|
||||
end
|
||||
|
||||
def guardian
|
||||
end
|
||||
end
|
||||
.new
|
||||
end
|
||||
|
||||
it "runs the provided service in the context of a controller" do
|
||||
endpoint
|
||||
expect(result).to be_a Chat::Service::Base::Context
|
||||
expect(result).to be_a_success
|
||||
end
|
||||
|
||||
context "when using the on_success action" do
|
||||
let(:actions) { <<-BLOCK }
|
||||
proc do
|
||||
on_success { :success }
|
||||
end
|
||||
BLOCK
|
||||
|
||||
context "when the service succeeds" do
|
||||
it "runs the provided block" do
|
||||
expect(endpoint).to eq :success
|
||||
end
|
||||
end
|
||||
|
||||
context "when the service does not succeed" do
|
||||
let(:service) { FailureService }
|
||||
|
||||
it "does not run the provided block" do
|
||||
expect(endpoint).not_to eq :success
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when using the on_failure action" do
|
||||
let(:actions) { <<-BLOCK }
|
||||
proc do
|
||||
on_failure { :fail }
|
||||
end
|
||||
BLOCK
|
||||
|
||||
context "when the service fails" do
|
||||
let(:service) { FailureService }
|
||||
|
||||
it "runs the provided block" do
|
||||
expect(endpoint).to eq :fail
|
||||
end
|
||||
end
|
||||
|
||||
context "when the service does not fail" do
|
||||
let(:service) { SuccessService }
|
||||
|
||||
it "does not run the provided block" do
|
||||
expect(endpoint).not_to eq :fail
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when using the on_failed_policy action" do
|
||||
let(:actions) { <<-BLOCK }
|
||||
proc do
|
||||
on_failed_policy(:test) { :policy_failure }
|
||||
end
|
||||
BLOCK
|
||||
|
||||
context "when the service policy fails" do
|
||||
let(:service) { FailedPolicyService }
|
||||
|
||||
it "runs the provided block" do
|
||||
expect(endpoint).to eq :policy_failure
|
||||
end
|
||||
end
|
||||
|
||||
context "when the service policy does not fail" do
|
||||
let(:service) { SuccessPolicyService }
|
||||
|
||||
it "does not run the provided block" do
|
||||
expect(endpoint).not_to eq :policy_failure
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when using the on_failed_contract action" do
|
||||
let(:actions) { <<-BLOCK }
|
||||
proc do
|
||||
on_failed_contract { :contract_failure }
|
||||
end
|
||||
BLOCK
|
||||
|
||||
context "when the service contract fails" do
|
||||
let(:service) { FailedContractService }
|
||||
|
||||
it "runs the provided block" do
|
||||
expect(endpoint).to eq :contract_failure
|
||||
end
|
||||
end
|
||||
|
||||
context "when the service contract does not fail" do
|
||||
let(:service) { SuccessContractService }
|
||||
|
||||
it "does not run the provided block" do
|
||||
expect(endpoint).not_to eq :contract_failure
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when using the on_model_not_found action" do
|
||||
let(:actions) { <<-BLOCK }
|
||||
->(*) do
|
||||
on_model_not_found(:fake_model) { :no_model }
|
||||
end
|
||||
BLOCK
|
||||
|
||||
context "when the service failed without a model" do
|
||||
let(:service) { FailureWithModelService }
|
||||
|
||||
it "runs the provided block" do
|
||||
expect(endpoint).to eq :no_model
|
||||
end
|
||||
end
|
||||
|
||||
context "when the service does not fail with a model" do
|
||||
let(:service) { SuccessWithModelService }
|
||||
|
||||
it "does not run the provided block" do
|
||||
expect(endpoint).not_to eq :no_model
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when using several actions together" do
|
||||
let(:service) { FailureService }
|
||||
let(:actions) { <<-BLOCK }
|
||||
proc do
|
||||
on_success { :success }
|
||||
on_failure { :failure }
|
||||
on_failed_policy { :policy_failure }
|
||||
end
|
||||
BLOCK
|
||||
|
||||
it "runs the first matching action" do
|
||||
expect(endpoint).to eq :failure
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,175 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe Chat::StepsInspector do
|
||||
class DummyService
|
||||
include Chat::Service::Base
|
||||
|
||||
model :model
|
||||
policy :policy
|
||||
contract
|
||||
transaction do
|
||||
step :in_transaction_step_1
|
||||
step :in_transaction_step_2
|
||||
end
|
||||
step :final_step
|
||||
|
||||
class Contract
|
||||
attribute :parameter
|
||||
|
||||
validates :parameter, presence: true
|
||||
end
|
||||
end
|
||||
|
||||
subject(:inspector) { described_class.new(result) }
|
||||
|
||||
let(:parameter) { "present" }
|
||||
let(:result) { DummyService.call(parameter: parameter) }
|
||||
|
||||
before do
|
||||
class DummyService
|
||||
%i[fetch_model policy in_transaction_step_1 in_transaction_step_2 final_step].each do |name|
|
||||
define_method(name) { true }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#inspect" do
|
||||
subject(:output) { inspector.inspect }
|
||||
|
||||
context "when service runs without error" do
|
||||
it "outputs all the steps of the service" do
|
||||
expect(output).to eq <<~OUTPUT.chomp
|
||||
[1/7] [model] 'model' ✅
|
||||
[2/7] [policy] 'policy' ✅
|
||||
[3/7] [contract] 'default' ✅
|
||||
[4/7] [transaction]
|
||||
[5/7] [step] 'in_transaction_step_1' ✅
|
||||
[6/7] [step] 'in_transaction_step_2' ✅
|
||||
[7/7] [step] 'final_step' ✅
|
||||
OUTPUT
|
||||
end
|
||||
end
|
||||
|
||||
context "when the model step is failing" do
|
||||
before do
|
||||
class DummyService
|
||||
def fetch_model
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it "shows the failing step" do
|
||||
expect(output).to eq <<~OUTPUT.chomp
|
||||
[1/7] [model] 'model' ❌
|
||||
[2/7] [policy] 'policy'
|
||||
[3/7] [contract] 'default'
|
||||
[4/7] [transaction]
|
||||
[5/7] [step] 'in_transaction_step_1'
|
||||
[6/7] [step] 'in_transaction_step_2'
|
||||
[7/7] [step] 'final_step'
|
||||
OUTPUT
|
||||
end
|
||||
end
|
||||
|
||||
context "when the policy step is failing" do
|
||||
before do
|
||||
class DummyService
|
||||
def policy
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it "shows the failing step" do
|
||||
expect(output).to eq <<~OUTPUT.chomp
|
||||
[1/7] [model] 'model' ✅
|
||||
[2/7] [policy] 'policy' ❌
|
||||
[3/7] [contract] 'default'
|
||||
[4/7] [transaction]
|
||||
[5/7] [step] 'in_transaction_step_1'
|
||||
[6/7] [step] 'in_transaction_step_2'
|
||||
[7/7] [step] 'final_step'
|
||||
OUTPUT
|
||||
end
|
||||
end
|
||||
|
||||
context "when the contract step is failing" do
|
||||
let(:parameter) { nil }
|
||||
|
||||
it "shows the failing step" do
|
||||
expect(output).to eq <<~OUTPUT.chomp
|
||||
[1/7] [model] 'model' ✅
|
||||
[2/7] [policy] 'policy' ✅
|
||||
[3/7] [contract] 'default' ❌
|
||||
[4/7] [transaction]
|
||||
[5/7] [step] 'in_transaction_step_1'
|
||||
[6/7] [step] 'in_transaction_step_2'
|
||||
[7/7] [step] 'final_step'
|
||||
OUTPUT
|
||||
end
|
||||
end
|
||||
|
||||
context "when a common step is failing" do
|
||||
before do
|
||||
class DummyService
|
||||
def in_transaction_step_2
|
||||
fail!("step error")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it "shows the failing step" do
|
||||
expect(output).to eq <<~OUTPUT.chomp
|
||||
[1/7] [model] 'model' ✅
|
||||
[2/7] [policy] 'policy' ✅
|
||||
[3/7] [contract] 'default' ✅
|
||||
[4/7] [transaction]
|
||||
[5/7] [step] 'in_transaction_step_1' ✅
|
||||
[6/7] [step] 'in_transaction_step_2' ❌
|
||||
[7/7] [step] 'final_step'
|
||||
OUTPUT
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#error" do
|
||||
subject(:error) { inspector.error }
|
||||
|
||||
context "when there are no errors" do
|
||||
it "returns nothing" do
|
||||
expect(error).to be_blank
|
||||
end
|
||||
end
|
||||
|
||||
context "when the model step is failing" do
|
||||
before do
|
||||
class DummyService
|
||||
def fetch_model
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it "returns an error related to the model" do
|
||||
expect(error).to match(/Model not found/)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the contract step is failing" do
|
||||
let(:parameter) { nil }
|
||||
|
||||
it "returns an error related to the contract" do
|
||||
expect(error).to match(/ActiveModel::Error attribute=parameter, type=blank, options={}/)
|
||||
end
|
||||
end
|
||||
|
||||
context "when a common step is failing" do
|
||||
before { result["result.step.final_step"].fail(error: "my error") }
|
||||
|
||||
it "returns an error related to the step" do
|
||||
expect(error).to eq("my error")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -22,4 +22,7 @@ module ChatSystemHelpers
|
||||
end
|
||||
end
|
||||
|
||||
RSpec.configure { |config| config.include ChatSystemHelpers, type: :system }
|
||||
RSpec.configure do |config|
|
||||
config.include ChatSystemHelpers, type: :system
|
||||
config.include Chat::ServiceMatchers
|
||||
end
|
||||
|
||||
@@ -170,12 +170,7 @@ RSpec.describe Chat::Api::ChatChannelsController do
|
||||
before { sign_in(current_user) }
|
||||
|
||||
it "returns an error" do
|
||||
delete "/chat/api/channels/#{channel_1.id}",
|
||||
params: {
|
||||
channel: {
|
||||
name_confirmation: channel_1.title(current_user),
|
||||
},
|
||||
}
|
||||
delete "/chat/api/channels/#{channel_1.id}"
|
||||
|
||||
expect(response.status).to eq(403)
|
||||
end
|
||||
@@ -190,38 +185,15 @@ RSpec.describe Chat::Api::ChatChannelsController do
|
||||
before { channel_1.destroy! }
|
||||
|
||||
it "returns an error" do
|
||||
delete "/chat/api/channels/#{channel_1.id}",
|
||||
params: {
|
||||
channel: {
|
||||
name_confirmation: channel_1.title(current_user),
|
||||
},
|
||||
}
|
||||
delete "/chat/api/channels/#{channel_1.id}"
|
||||
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the confirmation doesn’t match the channel name" do
|
||||
it "returns an error" do
|
||||
delete "/chat/api/channels/#{channel_1.id}",
|
||||
params: {
|
||||
channel: {
|
||||
name_confirmation: channel_1.title(current_user) + "foo",
|
||||
},
|
||||
}
|
||||
|
||||
expect(response.status).to eq(400)
|
||||
end
|
||||
end
|
||||
|
||||
context "with valid params" do
|
||||
it "properly destroys the channel" do
|
||||
delete "/chat/api/channels/#{channel_1.id}",
|
||||
params: {
|
||||
channel: {
|
||||
name_confirmation: channel_1.title(current_user),
|
||||
},
|
||||
}
|
||||
delete "/chat/api/channels/#{channel_1.id}"
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
expect(channel_1.reload.trashed?).to eq(true)
|
||||
@@ -243,14 +215,7 @@ RSpec.describe Chat::Api::ChatChannelsController do
|
||||
freeze_time(DateTime.parse("2022-07-08 09:30:00"))
|
||||
old_slug = channel_1.slug
|
||||
|
||||
delete(
|
||||
"/chat/api/channels/#{channel_1.id}",
|
||||
params: {
|
||||
channel: {
|
||||
name_confirmation: channel_1.title(current_user),
|
||||
},
|
||||
},
|
||||
)
|
||||
delete "/chat/api/channels/#{channel_1.id}"
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
expect(channel_1.reload.slug).to eq(
|
||||
@@ -371,7 +336,13 @@ RSpec.describe Chat::Api::ChatChannelsController do
|
||||
before { sign_in(Fabricate(:user)) }
|
||||
|
||||
it "returns a 403" do
|
||||
put "/chat/api/channels/#{channel.id}"
|
||||
put "/chat/api/channels/#{channel.id}",
|
||||
params: {
|
||||
channel: {
|
||||
name: "joffrey",
|
||||
description: "cat owner",
|
||||
},
|
||||
}
|
||||
|
||||
expect(response.status).to eq(403)
|
||||
end
|
||||
@@ -400,7 +371,7 @@ RSpec.describe Chat::Api::ChatChannelsController do
|
||||
it "nullifies the field and doesn’t store an empty string" do
|
||||
put "/chat/api/channels/#{channel.id}", params: { channel: { name: " " } }
|
||||
|
||||
expect(channel.reload.name).to be_nil
|
||||
expect(channel.reload.name).to eq(nil)
|
||||
end
|
||||
|
||||
it "doesn’t nullify the description" do
|
||||
@@ -421,7 +392,7 @@ RSpec.describe Chat::Api::ChatChannelsController do
|
||||
it "nullifies the field and doesn’t store an empty string" do
|
||||
put "/chat/api/channels/#{channel.id}", params: { channel: { description: " " } }
|
||||
|
||||
expect(channel.reload.description).to be_nil
|
||||
expect(channel.reload.description).to eq(nil)
|
||||
end
|
||||
|
||||
it "doesn’t nullify the name" do
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe(Chat::Service::TrashChannel) do
|
||||
subject(:result) { described_class.call(guardian: guardian) }
|
||||
|
||||
let(:guardian) { Guardian.new(current_user) }
|
||||
|
||||
context "when channel_id is not provided" do
|
||||
fab!(:current_user) { Fabricate(:admin) }
|
||||
|
||||
it { is_expected.to fail_to_find_a_model(:channel) }
|
||||
end
|
||||
|
||||
context "when channel_id is provided" do
|
||||
subject(:result) { described_class.call(channel_id: channel.id, guardian: guardian) }
|
||||
|
||||
fab!(:channel) { Fabricate(:chat_channel) }
|
||||
|
||||
context "when user is not allowed to perform the action" do
|
||||
fab!(:current_user) { Fabricate(:user) }
|
||||
|
||||
it { is_expected.to fail_a_policy(:invalid_access) }
|
||||
end
|
||||
|
||||
context "when user is allowed to perform the action" do
|
||||
fab!(:current_user) { Fabricate(:admin) }
|
||||
|
||||
it "sets the service result as successful" do
|
||||
expect(result).to be_a_success
|
||||
end
|
||||
|
||||
it "trashes the channel" do
|
||||
expect(result[:channel]).to be_trashed
|
||||
end
|
||||
|
||||
it "logs the action" do
|
||||
expect { result }.to change { UserHistory.count }.by(1)
|
||||
expect(UserHistory.last).to have_attributes(
|
||||
custom_type: "chat_channel_delete",
|
||||
details:
|
||||
"chat_channel_id: #{result[:channel].id}\nchat_channel_name: #{result[:channel].title(guardian.user)}",
|
||||
)
|
||||
end
|
||||
|
||||
it "changes the slug to prevent colisions" do
|
||||
expect(result[:channel].slug).to include("deleted")
|
||||
end
|
||||
|
||||
it "queues a job to delete channel relations" do
|
||||
expect { result }.to change(Jobs::ChatChannelDelete.jobs, :size).by(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,107 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe Chat::Service::UpdateChannel do
|
||||
subject(:result) { described_class.call(guardian: guardian, channel_id: channel.id, **params) }
|
||||
|
||||
fab!(:channel) { Fabricate(:chat_channel) }
|
||||
fab!(:current_user) { Fabricate(:admin) }
|
||||
|
||||
let(:guardian) { Guardian.new(current_user) }
|
||||
let(:params) do
|
||||
{
|
||||
name: "cool channel",
|
||||
description: "a channel description",
|
||||
slug: "snail",
|
||||
allow_channel_wide_mentions: true,
|
||||
auto_join_users: false,
|
||||
}
|
||||
end
|
||||
|
||||
context "when the user cannot edit the channel" do
|
||||
fab!(:current_user) { Fabricate(:user) }
|
||||
|
||||
it { is_expected.to fail_a_policy(:check_channel_permission) }
|
||||
end
|
||||
|
||||
context "when the user tries to edit a DM channel" do
|
||||
fab!(:channel) { Fabricate(:direct_message_channel, users: [current_user, Fabricate(:user)]) }
|
||||
|
||||
it { is_expected.to fail_a_policy(:no_direct_message_channel) }
|
||||
end
|
||||
|
||||
context "when channel is a category one" do
|
||||
context "when a valid user provides valid params" do
|
||||
let(:message) do
|
||||
MessageBus.track_publish(ChatPublisher::CHANNEL_EDITS_MESSAGE_BUS_CHANNEL) { result }.first
|
||||
end
|
||||
|
||||
it "sets the service result as successful" do
|
||||
expect(result).to be_a_success
|
||||
end
|
||||
|
||||
it "updates the channel accordingly" do
|
||||
result
|
||||
expect(channel.reload).to have_attributes(
|
||||
name: "cool channel",
|
||||
slug: "snail",
|
||||
description: "a channel description",
|
||||
allow_channel_wide_mentions: true,
|
||||
auto_join_users: false,
|
||||
)
|
||||
end
|
||||
|
||||
it "publishes a MessageBus message" do
|
||||
expect(message.data).to eq(
|
||||
{
|
||||
chat_channel_id: channel.id,
|
||||
name: "cool channel",
|
||||
description: "a channel description",
|
||||
slug: "snail",
|
||||
},
|
||||
)
|
||||
end
|
||||
|
||||
context "when the name is blank" do
|
||||
before { params[:name] = "" }
|
||||
|
||||
it "nils out the name" do
|
||||
result
|
||||
expect(channel.reload.name).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context "when the description is blank" do
|
||||
before do
|
||||
channel.update!(description: "something")
|
||||
params[:description] = ""
|
||||
end
|
||||
|
||||
it "nils out the description" do
|
||||
result
|
||||
expect(channel.reload.description).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context "when auto_join_users is set to 'true'" do
|
||||
before do
|
||||
channel.update!(auto_join_users: false)
|
||||
params[:auto_join_users] = true
|
||||
end
|
||||
|
||||
it "updates the model accordingly" do
|
||||
result
|
||||
expect(channel.reload).to have_attributes(auto_join_users: true)
|
||||
end
|
||||
|
||||
it "auto joins users" do
|
||||
expect_enqueued_with(
|
||||
job: :auto_manage_channel_memberships,
|
||||
args: {
|
||||
chat_channel_id: channel.id,
|
||||
},
|
||||
) { result }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe(Chat::Service::UpdateChannelStatus) do
|
||||
subject(:result) do
|
||||
described_class.call(guardian: guardian, channel_id: channel.id, status: status)
|
||||
end
|
||||
|
||||
fab!(:channel) { Fabricate(:chat_channel) }
|
||||
fab!(:current_user) { Fabricate(:admin) }
|
||||
|
||||
let(:guardian) { Guardian.new(current_user) }
|
||||
let(:status) { "open" }
|
||||
|
||||
context "when no channel_id is given" do
|
||||
subject(:result) { described_class.call(guardian: guardian, status: status) }
|
||||
|
||||
it { is_expected.to fail_to_find_a_model(:channel) }
|
||||
end
|
||||
|
||||
context "when user is not allowed to change channel status" do
|
||||
fab!(:current_user) { Fabricate(:user) }
|
||||
|
||||
it { is_expected.to fail_a_policy(:check_channel_permission) }
|
||||
end
|
||||
|
||||
context "when status is not allowed" do
|
||||
(ChatChannel.statuses.keys - ChatChannel.editable_statuses.keys).each do |na_status|
|
||||
context "when status is '#{na_status}'" do
|
||||
let(:status) { na_status }
|
||||
|
||||
it { is_expected.to fail_a_contract }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when new status is the same than the existing one" do
|
||||
let(:status) { channel.status }
|
||||
|
||||
it { is_expected.to fail_a_policy(:check_channel_permission) }
|
||||
end
|
||||
|
||||
context "when status is allowed" do
|
||||
let(:status) { "closed" }
|
||||
|
||||
it "sets the service result as successful" do
|
||||
expect(result).to be_a_success
|
||||
end
|
||||
|
||||
it "changes the status" do
|
||||
result
|
||||
expect(channel.reload).to be_closed
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,118 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe(Chat::Service::UpdateUserLastRead) do
|
||||
subject(:result) { described_class.call(params) }
|
||||
|
||||
fab!(:current_user) { Fabricate(:user) }
|
||||
fab!(:channel) { Fabricate(:chat_channel) }
|
||||
fab!(:membership) do
|
||||
Fabricate(:user_chat_channel_membership, user: current_user, chat_channel: channel)
|
||||
end
|
||||
fab!(:message_1) { Fabricate(:chat_message, chat_channel: membership.chat_channel) }
|
||||
|
||||
let(:guardian) { Guardian.new(current_user) }
|
||||
let(:params) do
|
||||
{
|
||||
guardian: guardian,
|
||||
user_id: current_user.id,
|
||||
channel_id: channel.id,
|
||||
message_id: message_1.id,
|
||||
}
|
||||
end
|
||||
|
||||
context "when channel_id is not provided" do
|
||||
before { params.delete(:channel_id) }
|
||||
|
||||
it { is_expected.to fail_to_find_a_model(:membership) }
|
||||
end
|
||||
|
||||
context "when user_id is not provided" do
|
||||
before { params.delete(:user_id) }
|
||||
|
||||
it { is_expected.to fail_to_find_a_model(:membership) }
|
||||
end
|
||||
|
||||
context "when user has no membership" do
|
||||
before { membership.destroy! }
|
||||
|
||||
it { is_expected.to fail_to_find_a_model(:membership) }
|
||||
end
|
||||
|
||||
context "when user can’t access the channel" do
|
||||
fab!(:membership) do
|
||||
Fabricate(
|
||||
:user_chat_channel_membership,
|
||||
user: current_user,
|
||||
chat_channel: Fabricate(:private_category_channel),
|
||||
)
|
||||
end
|
||||
|
||||
before { params[:channel_id] = membership.chat_channel.id }
|
||||
|
||||
it { is_expected.to fail_a_policy(:invalid_access) }
|
||||
end
|
||||
|
||||
context "when message_id is older than membership's last_read_message_id" do
|
||||
before do
|
||||
params[:message_id] = -2
|
||||
membership.update!(last_read_message_id: -1)
|
||||
end
|
||||
|
||||
it { is_expected.to fail_a_policy(:ensure_message_id_recency) }
|
||||
end
|
||||
|
||||
context "when message doesn’t exist" do
|
||||
before do
|
||||
params[:message_id] = 2
|
||||
membership.update!(last_read_message_id: 1)
|
||||
end
|
||||
|
||||
it { is_expected.to fail_a_policy(:ensure_message_exists) }
|
||||
end
|
||||
|
||||
context "when params are valid" do
|
||||
before { Jobs.run_immediately! }
|
||||
|
||||
it "sets the service result as successful" do
|
||||
expect(result).to be_a_success
|
||||
end
|
||||
|
||||
it "updates the last_read message id" do
|
||||
expect { result }.to change { membership.reload.last_read_message_id }.to(message_1.id)
|
||||
end
|
||||
|
||||
it "marks existing notifications related to the message as read" do
|
||||
expect {
|
||||
notification =
|
||||
Fabricate(
|
||||
:notification,
|
||||
notification_type: Notification.types[:chat_mention],
|
||||
user: current_user,
|
||||
)
|
||||
|
||||
# FIXME: we need a better way to create proper chat mention
|
||||
ChatMention.create!(notification: notification, user: current_user, chat_message: message_1)
|
||||
}.to change {
|
||||
Notification.where(
|
||||
notification_type: Notification.types[:chat_mention],
|
||||
user: current_user,
|
||||
read: false,
|
||||
).count
|
||||
}.by(1)
|
||||
|
||||
expect { result }.to change {
|
||||
Notification.where(
|
||||
notification_type: Notification.types[:chat_mention],
|
||||
user: current_user,
|
||||
read: false,
|
||||
).count
|
||||
}.by(-1)
|
||||
end
|
||||
|
||||
it "publishes new last read to clients" do
|
||||
messages = MessageBus.track_publish { result }
|
||||
|
||||
expect(messages.map(&:channel)).to include("/chat/user-tracking-state/#{current_user.id}")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,135 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
module ServiceMatchers
|
||||
class FailStep
|
||||
attr_reader :name, :result
|
||||
|
||||
def initialize(name)
|
||||
@name = name
|
||||
end
|
||||
|
||||
def matches?(result)
|
||||
@result = result
|
||||
step_exists? && step_failed? && service_failed?
|
||||
end
|
||||
|
||||
def failure_message
|
||||
message =
|
||||
if !step_exists?
|
||||
"Expected #{type} '#{name}' (key: '#{step}') was not found in the result object."
|
||||
elsif !step_failed?
|
||||
"Expected #{type} '#{name}' (key: '#{step}') to fail but it succeeded."
|
||||
else
|
||||
"expected the service to fail but it succeeded."
|
||||
end
|
||||
error_message_with_inspection(message)
|
||||
end
|
||||
|
||||
def failure_message_when_negated
|
||||
message = "Expected #{type} '#{name}' (key: '#{step}') to succeed but it failed."
|
||||
error_message_with_inspection(message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def step_exists?
|
||||
result[step].present?
|
||||
end
|
||||
|
||||
def step_failed?
|
||||
result[step].failure?
|
||||
end
|
||||
|
||||
def service_failed?
|
||||
result.failure?
|
||||
end
|
||||
|
||||
def type
|
||||
"step"
|
||||
end
|
||||
|
||||
def error_message_with_inspection(message)
|
||||
inspector = StepsInspector.new(result)
|
||||
"#{message}\n\n#{inspector.inspect}\n\n#{inspector.error}"
|
||||
end
|
||||
end
|
||||
|
||||
class FailContract < FailStep
|
||||
attr_reader :error_message
|
||||
|
||||
def step
|
||||
"result.contract.#{name}"
|
||||
end
|
||||
|
||||
def type
|
||||
"contract"
|
||||
end
|
||||
|
||||
def matches?(service)
|
||||
super && has_error?
|
||||
end
|
||||
|
||||
def has_error?
|
||||
result[step].errors.present?
|
||||
end
|
||||
|
||||
def failure_message
|
||||
return "expected contract '#{step}' to have errors" unless has_error?
|
||||
super
|
||||
end
|
||||
|
||||
def description
|
||||
"fail a contract named '#{name}'"
|
||||
end
|
||||
end
|
||||
|
||||
class FailPolicy < FailStep
|
||||
def type
|
||||
"policy"
|
||||
end
|
||||
|
||||
def step
|
||||
"result.policy.#{name}"
|
||||
end
|
||||
|
||||
def description
|
||||
"fail a policy named '#{name}'"
|
||||
end
|
||||
end
|
||||
|
||||
class FailToFindModel < FailStep
|
||||
def type
|
||||
"model"
|
||||
end
|
||||
|
||||
def step
|
||||
"result.model.#{name}"
|
||||
end
|
||||
|
||||
def description
|
||||
"fail to find a model named '#{name}'"
|
||||
end
|
||||
end
|
||||
|
||||
def fail_a_policy(name)
|
||||
FailPolicy.new(name)
|
||||
end
|
||||
|
||||
def fail_a_contract(name = "default")
|
||||
FailContract.new(name)
|
||||
end
|
||||
|
||||
def fail_to_find_a_model(name = "model")
|
||||
FailToFindModel.new(name)
|
||||
end
|
||||
|
||||
def inspect_steps(result)
|
||||
inspector = Chat::StepsInspector.new(result)
|
||||
puts "Steps:"
|
||||
puts inspector.inspect
|
||||
puts "\nFirst error:"
|
||||
puts inspector.error
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user