iachat/app/controllers/api/v1/accounts/internal_chat/channels_controller.rb
2026-05-06 06:24:46 -03:00

496 lines
16 KiB
Ruby

class Api::V1::Accounts::InternalChat::ChannelsController < Api::V1::Accounts::InternalChat::BaseController # rubocop:disable Metrics/ClassLength
include Events::Types
before_action :current_channel, only: [:show, :update, :destroy, :archive, :unarchive, :toggle_typing_status, :mark_read, :mark_unread]
RECENT_MESSAGES_LIMIT = 20
# Arbitrary 32-bit namespace for the private-channel limit advisory lock; paired with account id.
PRIVATE_CHANNEL_LOCK_KEY = 0x49434C4D # 'ICLM'
def index
authorize InternalChat::Channel, :index?
@channels = filtered_channels
@unread_counts = compute_unread_counts(@channels)
@mention_channel_ids = compute_mention_channel_ids(@channels)
render json: @channels.map { |channel| channel_index_response(channel) }
end
def show
authorize @current_channel, :show?
render json: channel_show_response(@current_channel)
end
def create
@channel = build_channel
authorize @channel, :create?
created = @channel.new_record?
if dm_params? && created
create_dm_with_lock
else
with_private_channel_limit_lock(@channel) do
return if enforce_private_channel_limit(@channel)
ActiveRecord::Base.transaction do
@channel.save!
add_creator_as_admin
add_initial_members
add_channel_type_members
end
end
end
dispatch_channel_event(@channel) if created
render json: channel_show_response(@channel), status: :created
end
def update
authorize @current_channel, :update?
attrs = update_channel_params
validate_category!(attrs[:category_id])
@current_channel.update!(attrs)
dispatch_channel_event(@current_channel)
render json: channel_show_response(@current_channel)
end
def destroy
authorize @current_channel, :destroy?
# Capture member tokens before destroying so the listener can broadcast to them
cached_tokens = channel_member_tokens(@current_channel)
@current_channel.destroy!
Rails.configuration.dispatcher.dispatch(INTERNAL_CHAT_CHANNEL_UPDATED, Time.zone.now, channel: @current_channel,
member_tokens: cached_tokens)
head :ok
end
def archive
authorize @current_channel, :archive?
head(:unprocessable_entity) and return if @current_channel.channel_type_dm?
@current_channel.archived!
dispatch_channel_event(@current_channel)
render json: channel_show_response(@current_channel)
end
def unarchive
authorize @current_channel, :unarchive?
with_private_channel_limit_lock(@current_channel) do
return if enforce_private_channel_limit(@current_channel)
@current_channel.active!
end
dispatch_channel_event(@current_channel)
render json: channel_show_response(@current_channel)
end
def toggle_typing_status
authorize @current_channel, :toggle_typing_status?
InternalChat::TypingStatusManager.new(
channel: @current_channel, user: Current.user, params: { typing_status: typing_status_param }
).perform
head :ok
end
def mark_read
authorize @current_channel, :mark_read?
membership = @current_channel.channel_members.find_by(user_id: Current.user.id)
membership&.update!(last_read_at: Time.current)
head :ok
end
def mark_unread
authorize @current_channel, :mark_unread?
msg_id = mark_unread_params[:message_id]
return head(:ok) if msg_id.blank?
membership = @current_channel.channel_members.find_by!(user_id: Current.user.id)
message = @current_channel.messages.find(msg_id)
membership.update!(last_read_at: message.created_at - 1.second)
head :ok
end
private
def enforce_private_channel_limit(channel)
return unless channel.channel_type_private_channel?
max = InternalChat::Limits.max_private_channels
return if max.blank?
count = Current.account.internal_chat_channels.where(channel_type: :private_channel).active.count
render_pro_required('private_channels') if count >= max
end
# Postgres advisory transaction lock keyed by account so concurrent create/unarchive
# cannot bypass the private-channel limit by racing between count and save.
def with_private_channel_limit_lock(channel)
return yield unless channel.channel_type_private_channel? && InternalChat::Limits.max_private_channels.present?
ActiveRecord::Base.transaction do
ActiveRecord::Base.connection.execute(
ActiveRecord::Base.sanitize_sql_array(['SELECT pg_advisory_xact_lock(?, ?)', PRIVATE_CHANNEL_LOCK_KEY, Current.account.id])
)
yield
end
end
def filtered_channels
channels = Current.account.internal_chat_channels.includes(channel_members: { user: :account_users }, category: [])
channels = apply_type_filter(channels)
channels = apply_category_filter(channels)
channels = apply_status_filter(channels)
channels = apply_visibility_filter(channels)
channels.order(last_activity_at: :desc)
end
def apply_type_filter(channels)
case params[:type]
when 'text_channels'
channels.text_channels
when 'direct_messages'
channels.direct_messages
else
channels
end
end
def apply_category_filter(channels)
return channels if params[:category_id].blank?
channels.where(category_id: params[:category_id])
end
def apply_status_filter(channels)
case params[:status]
when 'archived'
channels.archived
else
channels.active
end
end
def apply_visibility_filter(channels)
user_channels = channels.where(id: Current.user.internal_chat_channels.select(:id))
return channels.where(channel_type: %i[public_channel private_channel]).or(user_channels) if Current.account_user&.administrator?
channels.where(channel_type: :public_channel).or(user_channels)
end
def build_channel
if dm_params?
find_or_build_dm
else
attrs = create_channel_params.except(:member_ids, :team_ids)
validate_category!(attrs[:category_id])
Current.account.internal_chat_channels.build(attrs.merge(created_by: Current.user))
end
end
def dm_params?
params[:channel_type] == 'dm' || params.dig(:channel, :channel_type) == 'dm'
end
def find_or_build_dm
user_ids = dm_member_ids
existing_dm = find_existing_dm(user_ids)
return existing_dm if existing_dm.present?
Current.account.internal_chat_channels.build(
channel_type: :dm,
name: nil,
created_by: Current.user
)
end
def find_existing_dm(user_ids)
sorted_ids = user_ids.sort
member_count = sorted_ids.size
Current.account.internal_chat_channels
.where(channel_type: :dm)
.joins(:channel_members)
.group('internal_chat_channels.id')
.having('COUNT(internal_chat_channel_members.id) = ?', member_count)
.having(
'ARRAY_AGG(internal_chat_channel_members.user_id ORDER BY internal_chat_channel_members.user_id) = ARRAY[?]::bigint[]',
sorted_ids
)
.first
end
def dm_member_ids
ids = Array(permitted_member_ids).map(&:to_i)
ids = Current.account.users.where(id: ids).pluck(:id)
ids << Current.user.id unless ids.include?(Current.user.id)
ids
end
def add_creator_as_admin
return if @channel.channel_type_dm?
return if @channel.channel_members.exists?(user_id: Current.user.id)
@channel.channel_members.create!(user_id: Current.user.id, role: :admin)
end
def add_initial_members
member_ids = Array(permitted_member_ids).map(&:to_i)
member_ids = Current.account.users.where(id: member_ids).pluck(:id)
member_ids << Current.user.id if @channel.channel_type_dm? && member_ids.exclude?(Current.user.id)
member_ids.uniq.each do |user_id|
next if @channel.channel_members.exists?(user_id: user_id)
@channel.channel_members.create!(user_id: user_id, role: :member)
end
end
def add_channel_type_members
return if @channel.channel_type_dm?
if @channel.channel_type_public_channel?
add_all_agents_as_members
else
add_team_members
end
end
def add_all_agents_as_members
agent_ids = Current.account.agents.where.not(id: Current.user.id).pluck(:id)
agent_ids.each do |uid|
@channel.channel_members.find_or_create_by!(user_id: uid) { |m| m.role = :member }
end
end
def add_team_members
team_ids = permitted_team_ids
return if team_ids.blank?
team_ids.each do |team_id|
team = Current.account.teams.find_by(id: team_id)
next unless team
@channel.channel_teams.find_or_create_by!(team: team)
team.members.each do |user|
@channel.channel_members.find_or_create_by!(user_id: user.id) { |m| m.role = :member }
end
end
end
def create_channel_params
@create_channel_params ||= params.require(:channel).permit(:name, :description, :channel_type, :category_id, member_ids: [], team_ids: [])
end
def update_channel_params
params.require(:channel).permit(:name, :description, :category_id)
end
def permitted_member_ids
params.permit(member_ids: [])[:member_ids] || create_channel_params[:member_ids]
end
def permitted_team_ids
ids = params.permit(team_ids: [])[:team_ids] || create_channel_params[:team_ids]
Array(ids).map(&:to_i).compact_blank
end
def mark_unread_params
params.permit(:message_id)
end
def typing_status_param
params.permit(:typing_status)[:typing_status]
end
def create_dm_with_lock
lock_key = "internal_chat_dm_#{Current.account.id}_#{dm_member_ids.sort.join('_')}"
ActiveRecord::Base.transaction do
ActiveRecord::Base.connection.execute(
ActiveRecord::Base.sanitize_sql_array(['SELECT pg_advisory_xact_lock(?)', Zlib.crc32(lock_key)])
)
existing = find_existing_dm(dm_member_ids)
if existing
@channel = existing
else
@channel.save!
add_initial_members
end
end
end
def compute_mention_channel_ids(channels)
user_id = Current.user.id
InternalChat::ChannelMember
.joins(
'INNER JOIN internal_chat_messages ' \
'ON internal_chat_messages.internal_chat_channel_id = internal_chat_channel_members.internal_chat_channel_id ' \
'AND internal_chat_messages.created_at > internal_chat_channel_members.last_read_at'
)
.where(internal_chat_channel_id: channels.select(:id), user_id: user_id)
.where.not(last_read_at: nil)
.where.not('internal_chat_messages.sender_id' => user_id)
.where("internal_chat_messages.content_attributes->'mentioned_user_ids' @> ?", [user_id].to_json)
.pluck(Arel.sql('DISTINCT internal_chat_channel_members.internal_chat_channel_id'))
end
def compute_unread_counts(channels)
InternalChat::ChannelMember
.joins(
'INNER JOIN internal_chat_messages ' \
'ON internal_chat_messages.internal_chat_channel_id = internal_chat_channel_members.internal_chat_channel_id ' \
'AND internal_chat_messages.created_at > internal_chat_channel_members.last_read_at'
)
.where(internal_chat_channel_id: channels.select(:id), user_id: Current.user.id)
.where.not(last_read_at: nil)
.where.not('internal_chat_messages.sender_id' => Current.user.id)
.group('internal_chat_channel_members.internal_chat_channel_id')
.count('internal_chat_messages.id')
end
def channel_base_response(channel)
{
id: channel.id,
name: channel.name,
description: channel.description,
channel_type: channel.channel_type,
status: channel.status,
category_id: channel.category_id,
last_activity_at: channel.last_activity_at,
created_at: channel.created_at,
updated_at: channel.updated_at
}
end
def channel_index_response(channel) # rubocop:disable Metrics/AbcSize
membership = channel.channel_members.detect { |member| member.user_id == Current.user.id }
response = channel_base_response(channel).merge(
is_dm: channel.channel_type_dm?,
muted: membership&.muted || false,
favorited: membership&.favorited || false,
hidden: membership&.hidden || false,
members_count: channel.channel_members.size,
unread_count: @unread_counts&.dig(channel.id) || 0,
has_unread_mention: @mention_channel_ids&.include?(channel.id) || false
)
if channel.channel_type_dm?
response[:members] = channel.channel_members.map do |m|
{ user_id: m.user_id, name: m.user.name, avatar_url: m.user.avatar_url, availability_status: m.user.availability_status }
end
end
response
end
def channel_show_response(channel) # rubocop:disable Metrics/AbcSize
members = channel.channel_members.includes(:user).load
membership = members.detect { |member| member.user_id == Current.user.id }
recent_messages = channel.messages
.includes(:sender, :reactions, :replies, { poll: { options: { votes: :user } } },
attachments: { file_attachment: :blob })
.recent.limit(RECENT_MESSAGES_LIMIT).reverse
channel_base_response(channel).merge(
is_dm: channel.channel_type_dm?,
muted: membership&.muted || false,
favorited: membership&.favorited || false,
account_id: channel.account_id,
created_by_id: channel.created_by_id,
members_count: members.size,
unread_count: membership&.unread_messages_count || 0,
members: members.map { |m| member_response(m) },
messages: recent_messages.map { |msg| message_response(msg) }
)
end
def member_response(member)
{
id: member.id,
user_id: member.user_id,
role: member.role,
muted: member.muted,
favorited: member.favorited,
name: member.user.name,
avatar_url: member.user.avatar_url
}
end
def message_response(message)
deleted = message.content_attributes&.dig('deleted')
attrs = message.content_attributes || {}
attrs = attrs.merge(poll: poll_response_for(message.poll)) if message.poll.present?
{
id: message.id,
content: message.content,
content_type: message.content_type,
content_attributes: attrs,
sender: message.sender&.push_event_data,
parent_id: message.parent_id,
echo_id: message.echo_id,
replies_count: message.replies_count,
created_at: message.created_at,
updated_at: message.updated_at,
reactions: reaction_responses(message),
attachments: deleted ? [] : message.attachments.map { |a| attachment_response(a) }
}
end
def poll_response_for(poll)
{
id: poll.id,
question: poll.question,
multiple_choice: poll.multiple_choice,
public_results: poll.public_results,
allow_revote: poll.allow_revote,
expires_at: poll.expires_at,
internal_chat_message_id: poll.internal_chat_message_id,
options: poll.options.ordered.includes(votes: :user).map { |opt| poll_option_response(opt, poll) },
total_votes: poll.total_votes_count,
created_at: poll.created_at,
updated_at: poll.updated_at
}
end
def poll_option_response(option, poll)
response = {
id: option.id,
text: option.text,
votes_count: option.votes_count,
voted: option.votes.any? { |v| v.user_id == Current.user.id }
}
response[:voters] = option.votes.map { |v| { id: v.user_id, name: v.user.name } } if poll.public_results
response
end
def reaction_responses(message)
message.reactions.includes(:user).map do |r|
{ id: r.id, emoji: r.emoji, user_id: r.user_id, user: { name: r.user&.name } }
end
end
def attachment_response(attachment)
{
id: attachment.id,
file_type: attachment.file_type,
external_url: attachment.external_url,
extension: attachment.extension,
file_url: attachment.file.attached? ? url_for(attachment.file) : nil
}
end
def channel_member_tokens(channel)
users = channel.channel_type_public_channel? ? channel.account.users : channel.members
users.pluck(:pubsub_token)
end
def validate_category!(category_id)
return if category_id.blank?
Current.account.internal_chat_categories.find(category_id)
end
def dispatch_channel_event(channel)
Rails.configuration.dispatcher.dispatch(INTERNAL_CHAT_CHANNEL_UPDATED, Time.zone.now, channel: channel)
end
end