diff --git a/AGENTS.md b/AGENTS.md index 3db1c34a0..1a570ea73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,9 +79,9 @@ ## Project-Specific - **Translations**: - - Only update `en.yml` and `en.json` + - Update `en.yml`/`en.json` and `pt_BR.yml`/`pt_BR.json` - Other languages are handled by the community - - Backend i18n → `en.yml`, Frontend i18n → `en.json` + - Backend i18n → `.yml`, Frontend i18n → `.json` - **Frontend**: - Use `components-next/` for message bubbles (the rest is being deprecated) diff --git a/Rakefile b/Rakefile index 2e996417e..99dd20aa6 100644 --- a/Rakefile +++ b/Rakefile @@ -7,3 +7,12 @@ enterprise_tasks_path = Rails.root.join('enterprise/tasks_railtie.rb').to_s require enterprise_tasks_path if File.exist?(enterprise_tasks_path) Rails.application.load_tasks + +# Ensure the f_unaccent function used by internal chat search indexes is created +# before db:schema:load runs. This must happen after Rails.application.load_tasks +# so that both `db:schema:load` and `db:internal_chat:ensure_search_functions` +# are guaranteed to be defined. +if Rake::Task.task_defined?('db:schema:load') && + Rake::Task.task_defined?('db:internal_chat:ensure_search_functions') + Rake::Task['db:schema:load'].enhance(['db:internal_chat:ensure_search_functions']) +end diff --git a/app/controllers/api/v1/accounts/internal_chat/base_controller.rb b/app/controllers/api/v1/accounts/internal_chat/base_controller.rb new file mode 100644 index 000000000..f8f928bb8 --- /dev/null +++ b/app/controllers/api/v1/accounts/internal_chat/base_controller.rb @@ -0,0 +1,19 @@ +class Api::V1::Accounts::InternalChat::BaseController < Api::V1::Accounts::BaseController + private + + def current_channel + @current_channel ||= Current.account.internal_chat_channels.find(params[:channel_id] || params[:id]) + end + + def current_membership + @current_membership ||= current_channel.channel_members.find_by(user_id: Current.user.id) + end + + def channel_member? + current_channel.channel_type_public_channel? || current_membership.present? + end + + def render_pro_required(feature) + render json: { error: 'pro_feature_required', feature: feature }, status: :payment_required + end +end diff --git a/app/controllers/api/v1/accounts/internal_chat/categories_controller.rb b/app/controllers/api/v1/accounts/internal_chat/categories_controller.rb new file mode 100644 index 000000000..96619cb38 --- /dev/null +++ b/app/controllers/api/v1/accounts/internal_chat/categories_controller.rb @@ -0,0 +1,49 @@ +class Api::V1::Accounts::InternalChat::CategoriesController < Api::V1::Accounts::InternalChat::BaseController + before_action :fetch_category, only: [:update, :destroy] + + def index + authorize InternalChat::Category, :index? + @categories = Current.account.internal_chat_categories.ordered.includes(:channels) + render json: @categories.map { |category| category_response(category) } + end + + def create + authorize InternalChat::Category, :create? + @category = Current.account.internal_chat_categories.create!(category_params) + render json: category_response(@category), status: :created + end + + def update + authorize @category, :update? + @category.update!(category_params) + render json: category_response(@category) + end + + def destroy + authorize @category, :destroy? + @category.destroy! + head :ok + end + + private + + def fetch_category + @category = Current.account.internal_chat_categories.find(params[:id]) + end + + def category_params + params.require(:category).permit(:name, :position) + end + + def category_response(category) + { + id: category.id, + name: category.name, + position: category.position, + account_id: category.account_id, + channels_count: category.channels.size, + created_at: category.created_at, + updated_at: category.updated_at + } + end +end diff --git a/app/controllers/api/v1/accounts/internal_chat/channel_members_controller.rb b/app/controllers/api/v1/accounts/internal_chat/channel_members_controller.rb new file mode 100644 index 000000000..7401454f3 --- /dev/null +++ b/app/controllers/api/v1/accounts/internal_chat/channel_members_controller.rb @@ -0,0 +1,107 @@ +class Api::V1::Accounts::InternalChat::ChannelMembersController < Api::V1::Accounts::InternalChat::BaseController + include Events::Types + + before_action :current_channel + before_action :fetch_member, only: [:update, :destroy] + + def index + authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy + @members = current_channel.channel_members.includes(user: :account_users) + render json: @members.map { |member| member_response(member) } + end + + def create + authorize current_channel, :update?, policy_class: InternalChat::ChannelPolicy + members = create_channel_members(validated_user_ids, requested_role) + dispatch_member_update + render json: members.map { |member| member_response(member) }, status: :created + end + + def update + authorize_member_update! + @member.update!(member_update_params) + render json: member_response(@member) + end + + def destroy + authorize_member_destroy! + removed_user = @member.user + @member.destroy! + dispatch_member_update(removed_user: removed_user) + head :ok + end + + private + + def validated_user_ids + user_ids = Array(params[:user_ids] || [params[:user_id]]).compact.map(&:to_i) + valid_user_ids = Current.account.users.where(id: user_ids).pluck(:id) + raise ActionController::BadRequest, 'No valid user IDs provided' if valid_user_ids.empty? + + valid_user_ids + end + + def create_channel_members(user_ids, role) + ActiveRecord::Base.transaction do + user_ids.map do |user_id| + current_channel.channel_members.find_or_create_by!(user_id: user_id) do |m| + m.role = role + end + end + end + end + + # Only account administrators can promote a new member to channel admin via params. + # Channel admins (without account-admin) always create plain members. + def requested_role + return :member unless Current.account_user&.administrator? + return :member if params[:role].blank? + + InternalChat::ChannelMember.roles.key?(params[:role].to_s) ? params[:role] : :member + end + + def fetch_member + @member = current_channel.channel_members.find(params[:id]) + end + + def authorize_member_update! + raise Pundit::NotAuthorizedError unless @member.user_id == Current.user.id || Current.account_user&.administrator? + end + + def authorize_member_destroy! + raise Pundit::NotAuthorizedError unless @member.user_id == Current.user.id || Current.account_user&.administrator? + end + + def dispatch_member_update(removed_user: nil) + # Capture tokens before the broadcast so the removed user also receives the event + tokens = current_channel.members.pluck(:pubsub_token) + tokens << removed_user.pubsub_token if removed_user.present? + + Rails.configuration.dispatcher.dispatch( + INTERNAL_CHAT_CHANNEL_UPDATED, + Time.zone.now, + channel: current_channel, + member_tokens: tokens.uniq + ) + end + + def member_update_params + params.permit(:muted, :favorited, :hidden) + end + + def member_response(member) + { + id: member.id, + user_id: member.user_id, + role: member.role, + muted: member.muted, + favorited: member.favorited, + last_read_at: member.last_read_at, + name: member.user.name, + avatar_url: member.user.avatar_url, + availability_status: member.user.availability_status, + created_at: member.created_at, + updated_at: member.updated_at + } + end +end diff --git a/app/controllers/api/v1/accounts/internal_chat/channels_controller.rb b/app/controllers/api/v1/accounts/internal_chat/channels_controller.rb new file mode 100644 index 000000000..2deddaba7 --- /dev/null +++ b/app/controllers/api/v1/accounts/internal_chat/channels_controller.rb @@ -0,0 +1,495 @@ +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, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + 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, Metrics/CyclomaticComplexity + 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 diff --git a/app/controllers/api/v1/accounts/internal_chat/drafts_controller.rb b/app/controllers/api/v1/accounts/internal_chat/drafts_controller.rb new file mode 100644 index 000000000..0334d6f8b --- /dev/null +++ b/app/controllers/api/v1/accounts/internal_chat/drafts_controller.rb @@ -0,0 +1,55 @@ +class Api::V1::Accounts::InternalChat::DraftsController < Api::V1::Accounts::InternalChat::BaseController + before_action :current_channel, only: [:update, :destroy] + + def index + accessible_channel_ids = Current.account.internal_chat_channels + .where(channel_type: :public_channel) + .or(Current.account.internal_chat_channels.where(id: Current.user.internal_chat_channels.select(:id))) + .select(:id) + @drafts = InternalChat::Draft.where(user: Current.user, account: Current.account, + internal_chat_channel_id: accessible_channel_ids).recent + render json: @drafts.map { |draft| draft_response(draft) } + end + + def update + authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy + + @draft = InternalChat::Draft.find_or_initialize_by( + user: Current.user, + internal_chat_channel_id: current_channel.id, + parent_id: draft_params[:parent_id] + ) + @draft.assign_attributes( + account: Current.account, + content: draft_params[:content] + ) + @draft.save! + + render json: draft_response(@draft), status: :ok + end + + def destroy + authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy + + @draft = InternalChat::Draft.find_by!(user: Current.user, internal_chat_channel_id: current_channel.id, parent_id: params[:parent_id]) + @draft.destroy! + head :ok + end + + private + + def draft_params + params.permit(:content, :parent_id) + end + + def draft_response(draft) + { + id: draft.id, + content: draft.content, + internal_chat_channel_id: draft.internal_chat_channel_id, + parent_id: draft.parent_id, + created_at: draft.created_at, + updated_at: draft.updated_at + } + end +end diff --git a/app/controllers/api/v1/accounts/internal_chat/messages_controller.rb b/app/controllers/api/v1/accounts/internal_chat/messages_controller.rb new file mode 100644 index 000000000..9f0495177 --- /dev/null +++ b/app/controllers/api/v1/accounts/internal_chat/messages_controller.rb @@ -0,0 +1,191 @@ +class Api::V1::Accounts::InternalChat::MessagesController < Api::V1::Accounts::InternalChat::BaseController + include Events::Types + + before_action :current_channel + before_action :fetch_message, only: [:update, :destroy, :pin, :unpin, :thread] + + MESSAGES_PER_PAGE = 50 + + def index + authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy + @messages = paginated_messages + render json: { + messages: @messages.map { |msg| message_response(msg) }, + meta: pagination_meta + } + end + + def create + authorize current_channel, :show?, policy_class: InternalChat::ChannelPolicy + @message = InternalChat::MessageCreateService.new( + channel: current_channel, + sender: Current.user, + params: message_params + ).perform + render json: message_response(@message), status: :created + end + + def update + authorize @message, :update?, policy_class: InternalChat::MessagePolicy + previous_content = @message.content + @message.update!( + content: update_params[:content], + content_attributes: (@message.content_attributes || {}).merge('edited_at' => Time.current.iso8601, 'previous_content' => previous_content) + ) + dispatch_message_event(INTERNAL_CHAT_MESSAGE_UPDATED, message: @message) + render json: message_response(@message) + end + + def destroy + authorize @message, :destroy?, policy_class: InternalChat::MessagePolicy + message_data = { + id: @message.id, + channel_id: @message.internal_chat_channel_id, + account_id: @message.account_id + } + @message.update!(content: I18n.t('internal_chat.messages.deleted'), content_attributes: { deleted: true }) + dispatch_message_event(INTERNAL_CHAT_MESSAGE_DELETED, message_data: message_data) + head :ok + end + + def pin + authorize @message, :pin?, policy_class: InternalChat::MessagePolicy + @message.skip_content_validation = true + @message.update!(content_attributes: (@message.content_attributes || {}).merge('pinned' => true, 'pinned_by' => Current.user.id, + 'pinned_at' => Time.current.iso8601)) + dispatch_message_event(INTERNAL_CHAT_MESSAGE_UPDATED, message: @message) + render json: message_response(@message) + end + + def unpin + authorize @message, :unpin?, policy_class: InternalChat::MessagePolicy + @message.skip_content_validation = true + attrs = (@message.content_attributes || {}).except('pinned', 'pinned_by', 'pinned_at') + @message.update!(content_attributes: attrs) + dispatch_message_event(INTERNAL_CHAT_MESSAGE_UPDATED, message: @message) + render json: message_response(@message) + end + + def thread + authorize @message, :thread?, policy_class: InternalChat::MessagePolicy + replies = @message.replies.includes(:sender, :reactions, :replies, :attachments, :poll).ordered + render json: { + parent: message_response(@message), + replies: replies.map { |msg| message_response(msg) } + } + end + + private + + def fetch_message + @message = current_channel.messages.find(params[:id]) + end + + def paginated_messages + return fetch_around_messages if params[:around].present? + + messages = apply_time_filters(base_messages_scope) + if params[:after].present? + messages.ordered.limit(MESSAGES_PER_PAGE) + else + messages.ordered.last(MESSAGES_PER_PAGE) + end + rescue ArgumentError + base_messages_scope.ordered.last(MESSAGES_PER_PAGE) + end + + def fetch_around_messages + target = current_channel.messages.find_by(id: params[:around]) + return base_messages_scope.ordered.last(MESSAGES_PER_PAGE) unless target + + half = MESSAGES_PER_PAGE / 2 + before_msgs = base_messages_scope.where('internal_chat_messages.created_at <= ?', target.created_at) + .ordered.last(half) + after_msgs = base_messages_scope.where('internal_chat_messages.created_at > ?', target.created_at) + .ordered.limit(half) + (before_msgs + after_msgs).uniq(&:id).sort_by(&:created_at) + end + + def base_messages_scope + current_channel.messages + .includes(:sender, :reactions, :replies, :attachments, :poll) + .where("parent_id IS NULL OR (content_attributes->>'also_send_in_channel')::boolean = true") + end + + def apply_time_filters(messages) + messages = messages.where('internal_chat_messages.created_at < ?', Time.zone.parse(params[:before])) if params[:before].present? + messages = messages.where('internal_chat_messages.created_at > ?', Time.zone.parse(params[:after])) if params[:after].present? + messages + end + + def pagination_meta + { + has_more: @messages.size >= MESSAGES_PER_PAGE + } + end + + def message_params + params.permit(:content, :content_type, :parent_id, :echo_id, :also_send_in_channel, attachments: [:file, :file_type]) + end + + def update_params + params.permit(:content) + end + + def message_response(message) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + deleted = message.content_attributes&.dig('deleted') + response = { + id: message.id, + content: message.content, + content_type: message.content_type, + content_attributes: message.content_attributes, + internal_chat_channel_id: message.internal_chat_channel_id, + 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: message.reactions.includes(:user).map { |r| { id: r.id, emoji: r.emoji, user_id: r.user_id, user: { name: r.user&.name } } }, + attachments: deleted ? [] : message.attachments.map { |a| attachment_response(a) } + } + response[:poll] = poll_data(message.poll) if !deleted && message.poll? + response + end + + def poll_data(poll) + return nil unless 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, + options: poll.options.ordered.includes(votes: :user).map { |o| poll_option_data(o, poll) }, + total_votes: poll.total_votes_count + } + end + + def poll_option_data(option, poll) + data = { id: option.id, text: option.text, emoji: option.emoji, votes_count: option.votes_count, + voted: option.votes.any? { |v| v.user_id == Current.user.id } } + data[:voters] = option.votes.map { |v| v.user.push_event_data } if poll.public_results + data + 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 dispatch_message_event(event, data) + Rails.configuration.dispatcher.dispatch(event, Time.zone.now, **data) + end +end diff --git a/app/controllers/api/v1/accounts/internal_chat/polls_controller.rb b/app/controllers/api/v1/accounts/internal_chat/polls_controller.rb new file mode 100644 index 000000000..12849f144 --- /dev/null +++ b/app/controllers/api/v1/accounts/internal_chat/polls_controller.rb @@ -0,0 +1,177 @@ +class Api::V1::Accounts::InternalChat::PollsController < Api::V1::Accounts::InternalChat::BaseController + include Events::Types + + before_action :set_poll, only: [:vote] + before_action :set_poll_for_unvote, only: [:unvote] + + def create + return render_pro_required('polls') unless InternalChat::Limits.polls_enabled? + + @channel = Current.account.internal_chat_channels.find(params[:channel_id]) + authorize @channel, :show?, policy_class: InternalChat::ChannelPolicy + raise ActionController::BadRequest, 'Options are required' if poll_params[:options].blank? + + ActiveRecord::Base.transaction do + @message = create_poll_message + @poll = build_poll + create_poll_options + end + + dispatch_message_created_event + + render json: message_with_poll_response(@message, @poll), status: :created + end + + def vote + ActiveRecord::Base.transaction do + validate_vote! + @vote = @option.votes.create!(user: Current.user) + end + dispatch_poll_event + + render json: message_with_poll_response(@poll.message, @poll.reload), status: :ok + end + + def unvote + raise ActionController::BadRequest, 'Poll has expired' if @poll.expired? + + @vote = if params[:option_id].present? + option = @poll.options.find(params[:option_id]) + option.votes.find_by!(user_id: Current.user.id) + else + InternalChat::PollVote.joins(:option) + .where(internal_chat_poll_options: { internal_chat_poll_id: @poll.id }, user_id: Current.user.id) + .first! + end + @vote.destroy! + dispatch_poll_event + + render json: message_with_poll_response(@poll.message, @poll.reload), status: :ok + end + + private + + def set_poll + @poll = InternalChat::Poll.joins(:message).where(internal_chat_messages: { account_id: Current.account.id }).find(params[:id]) + @option = @poll.options.find(params[:option_id]) + channel = @poll.message.channel + authorize channel, :show?, policy_class: InternalChat::ChannelPolicy + end + + def set_poll_for_unvote + @poll = InternalChat::Poll.joins(:message).where(internal_chat_messages: { account_id: Current.account.id }).find(params[:id]) + channel = @poll.message.channel + authorize channel, :show?, policy_class: InternalChat::ChannelPolicy + end + + def create_poll_message + @channel.messages.create!( + account: Current.account, + sender: Current.user, + content: poll_params[:question], + content_type: :poll + ) + end + + def build_poll + @message.create_poll!( + question: poll_params[:question], + multiple_choice: poll_params[:multiple_choice] || false, + public_results: poll_params.fetch(:public_results, true), + allow_revote: poll_params.fetch(:allow_revote, true), + expires_at: poll_params[:expires_at] + ) + end + + def validate_vote! + raise ActionController::BadRequest, 'Poll has expired' if @poll.expired? + + existing_votes = existing_user_votes + return unless existing_votes.exists? + + raise ActionController::BadRequest, 'Revoting is not allowed' unless @poll.allow_revote + + if @poll.multiple_choice + raise ActionController::BadRequest, 'Already voted for this option' if @option.votes.exists?(user_id: Current.user.id) + else + existing_votes.destroy_all + end + end + + def existing_user_votes + InternalChat::PollVote.joins(:option).where( + internal_chat_poll_options: { internal_chat_poll_id: @poll.id }, + user_id: Current.user.id + ) + end + + def create_poll_options + poll_params[:options].each_with_index do |option_attrs, index| + @poll.options.create!( + text: option_attrs[:text], + emoji: option_attrs[:emoji], + image_url: option_attrs[:image_url], + position: index + ) + end + end + + def poll_params + params.permit(:question, :multiple_choice, :public_results, :allow_revote, :expires_at, :channel_id, + options: [:text, :emoji, :image_url]) + end + + def message_with_poll_response(message, poll) + { + id: message.id, + content: message.content, + content_type: message.content_type, + content_attributes: (message.content_attributes || {}).merge(poll: poll_response(poll)), + internal_chat_channel_id: message.internal_chat_channel_id, + sender: message.sender.push_event_data, + parent_id: message.parent_id, + created_at: message.created_at, + updated_at: message.updated_at, + attachments: [], + reactions: [] + } + end + + def poll_response(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 { |option| option_response(option, poll) }, + total_votes: poll.total_votes_count, + created_at: poll.created_at, + updated_at: poll.updated_at + } + end + + def option_response(option, poll) + response = { + id: option.id, + text: option.text, + emoji: option.emoji, + image_url: option.image_url, + position: option.position, + votes_count: option.votes_count, + voted: option.votes.any? { |v| v.user_id == Current.user.id } + } + response[:voters] = option.votes.map { |v| v.user.push_event_data } if poll.public_results + response + end + + def dispatch_message_created_event + Rails.configuration.dispatcher.dispatch(INTERNAL_CHAT_MESSAGE_CREATED, Time.zone.now, message: @message) + end + + def dispatch_poll_event + Rails.configuration.dispatcher.dispatch(INTERNAL_CHAT_POLL_VOTED, Time.zone.now, poll: @poll, message: @poll.message) + end +end diff --git a/app/controllers/api/v1/accounts/internal_chat/reactions_controller.rb b/app/controllers/api/v1/accounts/internal_chat/reactions_controller.rb new file mode 100644 index 000000000..ef8c95ec7 --- /dev/null +++ b/app/controllers/api/v1/accounts/internal_chat/reactions_controller.rb @@ -0,0 +1,54 @@ +class Api::V1::Accounts::InternalChat::ReactionsController < Api::V1::Accounts::InternalChat::BaseController + include Events::Types + + before_action :fetch_message + + def create + @reaction = @message.reactions.build(user: Current.user, emoji: reaction_params[:emoji]) + authorize @reaction, :create?, policy_class: InternalChat::ReactionPolicy + @reaction.save! + dispatch_reaction_event(INTERNAL_CHAT_REACTION_CREATED, reaction: @reaction) + render json: reaction_response(@reaction), status: :created + end + + def destroy + @reaction = @message.reactions.find(params[:id]) + authorize @reaction, :destroy?, policy_class: InternalChat::ReactionPolicy + reaction_data = { + id: @reaction.id, + message_id: @reaction.internal_chat_message_id, + channel_id: @message.internal_chat_channel_id, + account_id: @message.account_id, + user_id: @reaction.user_id, + emoji: @reaction.emoji + } + @reaction.destroy! + dispatch_reaction_event(INTERNAL_CHAT_REACTION_DELETED, reaction_data: reaction_data) + head :ok + end + + private + + def fetch_message + @message = InternalChat::Message.joins(:channel).where(internal_chat_channels: { account_id: Current.account.id }).find(params[:message_id]) + end + + def reaction_response(reaction) + { + id: reaction.id, + emoji: reaction.emoji, + user_id: reaction.user_id, + user: { name: reaction.user&.name }, + internal_chat_message_id: reaction.internal_chat_message_id, + created_at: reaction.created_at + } + end + + def reaction_params + params.permit(:emoji) + end + + def dispatch_reaction_event(event, **data) + Rails.configuration.dispatcher.dispatch(event, Time.zone.now, **data) + end +end diff --git a/app/controllers/api/v1/accounts/internal_chat/search_controller.rb b/app/controllers/api/v1/accounts/internal_chat/search_controller.rb new file mode 100644 index 000000000..aa721b3c2 --- /dev/null +++ b/app/controllers/api/v1/accounts/internal_chat/search_controller.rb @@ -0,0 +1,19 @@ +class Api::V1::Accounts::InternalChat::SearchController < Api::V1::Accounts::BaseController + def show + authorize InternalChat::Channel, :index? + + result = InternalChat::SearchService.new( + current_user: Current.user, + current_account: Current.account, + params: search_params + ).perform + + render json: result + end + + private + + def search_params + params.permit(:q, :page) + end +end diff --git a/app/dispatchers/async_dispatcher.rb b/app/dispatchers/async_dispatcher.rb index f46928d4e..12f728453 100644 --- a/app/dispatchers/async_dispatcher.rb +++ b/app/dispatchers/async_dispatcher.rb @@ -15,6 +15,7 @@ class AsyncDispatcher < BaseDispatcher CsatSurveyListener.instance, HookListener.instance, InstallationWebhookListener.instance, + InternalChatListener.instance, NotificationListener.instance, ParticipationListener.instance, ReportingEventListener.instance, diff --git a/app/javascript/dashboard/api/internalChatChannels.js b/app/javascript/dashboard/api/internalChatChannels.js new file mode 100644 index 000000000..5855a4bf2 --- /dev/null +++ b/app/javascript/dashboard/api/internalChatChannels.js @@ -0,0 +1,72 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class InternalChatChannelsAPI extends ApiClient { + constructor() { + super('internal_chat/channels', { accountScoped: true }); + } + + getWithParams(params) { + return axios.get(this.url, { params }); + } + + getCategories() { + return axios.get(`${this.url.replace('/channels', '/categories')}`); + } + + createCategory(data) { + return axios.post(`${this.url.replace('/channels', '/categories')}`, data); + } + + deleteCategory(categoryId) { + return axios.delete( + `${this.url.replace('/channels', '/categories')}/${categoryId}` + ); + } + + archive(channelId) { + return axios.post(`${this.url}/${channelId}/archive`); + } + + unarchive(channelId) { + return axios.post(`${this.url}/${channelId}/unarchive`); + } + + getMembers(channelId) { + return axios.get(`${this.url}/${channelId}/members`); + } + + addMember(channelId, userId) { + return axios.post(`${this.url}/${channelId}/members`, { user_id: userId }); + } + + removeMember(channelId, memberId) { + return axios.delete(`${this.url}/${channelId}/members/${memberId}`); + } + + updateMember(channelId, memberId, data) { + return axios.patch(`${this.url}/${channelId}/members/${memberId}`, data); + } + + toggleTypingStatus(channelId, typingStatus) { + return axios.post(`${this.url}/${channelId}/toggle_typing_status`, { + typing_status: typingStatus, + }); + } + + markRead(channelId) { + return axios.post(`${this.url}/${channelId}/mark_read`); + } + + markUnread(channelId, messageId) { + return axios.post(`${this.url}/${channelId}/mark_unread`, { + message_id: messageId, + }); + } + + search(params) { + return axios.get(`${this.url.replace('/channels', '/search')}`, { params }); + } +} + +export default new InternalChatChannelsAPI(); diff --git a/app/javascript/dashboard/api/internalChatDrafts.js b/app/javascript/dashboard/api/internalChatDrafts.js new file mode 100644 index 000000000..4eaffeb35 --- /dev/null +++ b/app/javascript/dashboard/api/internalChatDrafts.js @@ -0,0 +1,24 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class InternalChatDraftsAPI extends ApiClient { + constructor() { + super('internal_chat', { accountScoped: true }); + } + + getDrafts() { + return axios.get(`${this.url}/drafts`); + } + + saveDraft(channelId, data) { + return axios.patch(`${this.url}/channels/${channelId}/draft`, data); + } + + deleteDraft(channelId, { parentId } = {}) { + return axios.delete(`${this.url}/channels/${channelId}/draft`, { + params: { parent_id: parentId }, + }); + } +} + +export default new InternalChatDraftsAPI(); diff --git a/app/javascript/dashboard/api/internalChatMessages.js b/app/javascript/dashboard/api/internalChatMessages.js new file mode 100644 index 000000000..efa32bc04 --- /dev/null +++ b/app/javascript/dashboard/api/internalChatMessages.js @@ -0,0 +1,62 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class InternalChatMessagesAPI extends ApiClient { + constructor() { + super('internal_chat/channels', { accountScoped: true }); + } + + getMessages(channelId, params = {}) { + return axios.get(`${this.url}/${channelId}/messages`, { params }); + } + + createMessage(channelId, data, files = []) { + if (files.length === 0) { + return axios.post(`${this.url}/${channelId}/messages`, data); + } + const formData = new FormData(); + if (data.content) formData.append('content', data.content); + if (data.parent_id) formData.append('parent_id', data.parent_id); + if (data.echo_id) formData.append('echo_id', data.echo_id); + files.forEach(file => { + formData.append('attachments[][file]', file); + }); + return axios.post(`${this.url}/${channelId}/messages`, formData); + } + + updateMessage(channelId, messageId, data) { + return axios.patch(`${this.url}/${channelId}/messages/${messageId}`, data); + } + + deleteMessage(channelId, messageId) { + return axios.delete(`${this.url}/${channelId}/messages/${messageId}`); + } + + getThread(channelId, messageId) { + return axios.get(`${this.url}/${channelId}/messages/${messageId}/thread`); + } + + pinMessage(channelId, messageId) { + return axios.post(`${this.url}/${channelId}/messages/${messageId}/pin`); + } + + unpinMessage(channelId, messageId) { + return axios.delete(`${this.url}/${channelId}/messages/${messageId}/unpin`); + } + + addReaction(messageId, emoji) { + const baseUrl = this.url.replace('/channels', ''); + return axios.post(`${baseUrl}/messages/${messageId}/reactions`, { + emoji, + }); + } + + removeReaction(messageId, reactionId) { + const baseUrl = this.url.replace('/channels', ''); + return axios.delete( + `${baseUrl}/messages/${messageId}/reactions/${reactionId}` + ); + } +} + +export default new InternalChatMessagesAPI(); diff --git a/app/javascript/dashboard/api/internalChatPolls.js b/app/javascript/dashboard/api/internalChatPolls.js new file mode 100644 index 000000000..14734cbdc --- /dev/null +++ b/app/javascript/dashboard/api/internalChatPolls.js @@ -0,0 +1,24 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class InternalChatPollsAPI extends ApiClient { + constructor() { + super('internal_chat/polls', { accountScoped: true }); + } + + createPoll(data) { + return axios.post(this.url, data); + } + + vote(pollId, optionId) { + return axios.post(`${this.url}/${pollId}/vote`, { option_id: optionId }); + } + + unvote(pollId, optionId) { + return axios.delete(`${this.url}/${pollId}/vote`, { + params: { option_id: optionId }, + }); + } +} + +export default new InternalChatPollsAPI(); diff --git a/app/javascript/dashboard/components-next/dialog/Dialog.vue b/app/javascript/dashboard/components-next/dialog/Dialog.vue index ca3ce41d4..fa5ee26ce 100644 --- a/app/javascript/dashboard/components-next/dialog/Dialog.vue +++ b/app/javascript/dashboard/components-next/dialog/Dialog.vue @@ -118,22 +118,25 @@ defineExpose({ open, close });
-
+

{{ title }}

@@ -143,12 +146,14 @@ defineExpose({ open, close });

- +
+ +