diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index 9d88ec3ff60..eb36f466d21 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -411,6 +411,10 @@ $mobile-breakpoint: 700px; .admin-container { margin-top: 10px; + .admin-section { + margin-bottom: 1em; + } + .username { input { min-width: 15em; diff --git a/plugins/chat/app/controllers/chat/admin/export_controller.rb b/plugins/chat/app/controllers/chat/admin/export_controller.rb new file mode 100644 index 00000000000..7872e46f61b --- /dev/null +++ b/plugins/chat/app/controllers/chat/admin/export_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Chat + module Admin + class ExportController < ::Admin::AdminController + requires_plugin Chat::PLUGIN_NAME + + def export_messages + entity = "chat_message" + Jobs.enqueue(:export_csv_file, entity: entity, user_id: current_user.id) + StaffActionLogger.new(current_user).log_entity_export(entity) + end + end + end +end diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/admin/export-messages.hbs b/plugins/chat/assets/javascripts/discourse/components/chat/admin/export-messages.hbs new file mode 100644 index 00000000000..dc88b34305d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/admin/export-messages.hbs @@ -0,0 +1,10 @@ +
+

{{i18n "chat.admin.export_messages.title"}}

+

{{i18n "chat.admin.export_messages.description"}}

+ +
\ No newline at end of file diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/admin/export-messages.js b/plugins/chat/assets/javascripts/discourse/components/chat/admin/export-messages.js new file mode 100644 index 00000000000..48fc90a3a52 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat/admin/export-messages.js @@ -0,0 +1,22 @@ +import Component from "@ember/component"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import I18n from "I18n"; + +export default class ExportMessages extends Component { + @service chatAdminApi; + @service dialog; + + @action + async exportMessages() { + try { + await this.chatAdminApi.exportMessages(); + this.dialog.alert( + I18n.t("chat.admin.export_messages.export_has_started") + ); + } catch (error) { + popupAjaxError(error); + } + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-admin-api.js b/plugins/chat/assets/javascripts/discourse/services/chat-admin-api.js new file mode 100644 index 00000000000..38025d89b26 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/services/chat-admin-api.js @@ -0,0 +1,19 @@ +import Service from "@ember/service"; +import { ajax } from "discourse/lib/ajax"; + +export default class ChatAdminApi extends Service { + async exportMessages() { + await this.#post(`/export/messages`); + } + + get #basePath() { + return "/chat/admin"; + } + + #post(endpoint, data = {}) { + return ajax(`${this.#basePath}${endpoint}`, { + type: "POST", + data, + }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/templates/admin-plugins-chat.hbs b/plugins/chat/assets/javascripts/discourse/templates/admin-plugins-chat.hbs index bbab48d73b3..521d3b889a6 100644 --- a/plugins/chat/assets/javascripts/discourse/templates/admin-plugins-chat.hbs +++ b/plugins/chat/assets/javascripts/discourse/templates/admin-plugins-chat.hbs @@ -1,3 +1,5 @@ + + {{#if this.selectedWebhook}} "summaries#get_summary" end + namespace :admin, defaults: { format: :json, constraints: StaffConstraint.new } do + post "export/messages" => "export#export_messages" + end + # direct_messages_controller routes get "/direct_messages" => "direct_messages#index" post "/direct_messages/create" => "direct_messages#create" diff --git a/plugins/chat/lib/chat/messages_exporter.rb b/plugins/chat/lib/chat/messages_exporter.rb new file mode 100644 index 00000000000..dec82d31cf7 --- /dev/null +++ b/plugins/chat/lib/chat/messages_exporter.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Chat + module MessagesExporter + LIMIT = 10_000 + + def chat_message_export + Chat::Message + .unscoped + .where(created_at: 6.months.ago..Time.current) + .joins(:chat_channel) + .joins(:user) + .joins("INNER JOIN users last_editors ON chat_messages.last_editor_id = last_editors.id") + .order(:created_at) + .limit(LIMIT) + .pluck( + "chat_messages.id", + "chat_channels.id", + "chat_channels.name", + "users.id", + "users.username", + "chat_messages.message", + "chat_messages.cooked", + "chat_messages.created_at", + "chat_messages.updated_at", + "chat_messages.deleted_at", + "chat_messages.in_reply_to_id", + "last_editors.id", + "last_editors.username", + ) + end + + def get_header(entity) + if entity === "chat_message" + %w[ + id + chat_channel_id + chat_channel_name + user_id + username + message + cooked + created_at + updated_at + deleted_at + in_reply_to_id + last_editor_id + last_editor_username + ] + else + super + end + end + end +end diff --git a/plugins/chat/plugin.rb b/plugins/chat/plugin.rb index d041adfeb3b..9f797d04800 100644 --- a/plugins/chat/plugin.rb +++ b/plugins/chat/plugin.rb @@ -63,6 +63,7 @@ after_initialize do User.prepend Chat::UserExtension Jobs::UserEmail.prepend Chat::UserEmailExtension Plugin::Instance.prepend Chat::PluginInstanceExtension + Jobs::ExportCsvFile.class_eval { prepend Chat::MessagesExporter } end if Oneboxer.respond_to?(:register_local_handler) diff --git a/plugins/chat/spec/lib/chat/messages_exporter_spec.rb b/plugins/chat/spec/lib/chat/messages_exporter_spec.rb new file mode 100644 index 00000000000..4643accf45d --- /dev/null +++ b/plugins/chat/spec/lib/chat/messages_exporter_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +describe Chat::MessagesExporter do + fab!(:public_channel) { Fabricate(:chat_channel) } + fab!(:public_channel_message_1) { Fabricate(:chat_message, chat_channel: public_channel) } + fab!(:public_channel_message_2) { Fabricate(:chat_message, chat_channel: public_channel) } + # this message is deleted in the before block: + fab!(:deleted_message) { Fabricate(:chat_message, chat_channel: public_channel) } + + fab!(:private_channel) { Fabricate(:private_category_channel, group: Fabricate(:group)) } + fab!(:private_channel_message_1) { Fabricate(:chat_message, chat_channel: private_channel) } + fab!(:private_channel_message_2) { Fabricate(:chat_message, chat_channel: private_channel) } + + fab!(:user_1) { Fabricate(:user) } + fab!(:user_2) { Fabricate(:user) } + fab!(:dm_channel) { Fabricate(:direct_message_channel, users: [user_1, user_2]) } + fab!(:direct_message_1) { Fabricate(:chat_message, chat_channel: private_channel, user: user_1) } + fab!(:direct_message_2) { Fabricate(:chat_message, chat_channel: private_channel, user: user_2) } + + before { deleted_message.trash! } + + it "exports messages" do + exporter = Class.new.extend(Chat::MessagesExporter) + + result = exporter.chat_message_export.to_a + + expect(result.length).to be(7) + assert_exported_message(result[0], public_channel_message_1) + assert_exported_message(result[1], public_channel_message_2) + assert_exported_message(result[2], deleted_message) + assert_exported_message(result[3], private_channel_message_1) + assert_exported_message(result[4], private_channel_message_2) + assert_exported_message(result[5], direct_message_1) + assert_exported_message(result[6], direct_message_2) + end + + def assert_exported_message(data_row, message) + expect(data_row[0]).to eq(message.id) + expect(data_row[1]).to eq(message.chat_channel.id) + expect(data_row[2]).to eq(message.chat_channel.name) + expect(data_row[3]).to eq(message.user.id) + expect(data_row[4]).to eq(message.user.username) + expect(data_row[5]).to eq(message.message) + expect(data_row[6]).to eq(message.cooked) + expect(data_row[7]).to eq_time(message.created_at) + expect(data_row[8]).to eq_time(message.updated_at) + expect(data_row[9]).to eq_time(message.deleted_at) + expect(data_row[10]).to eq(message.in_reply_to_id) + expect(data_row[11]).to eq(message.last_editor.id) + expect(data_row[12]).to eq(message.last_editor.username) + end +end diff --git a/plugins/chat/spec/requests/admin/export_controller_spec.rb b/plugins/chat/spec/requests/admin/export_controller_spec.rb new file mode 100644 index 00000000000..8ba0f278ade --- /dev/null +++ b/plugins/chat/spec/requests/admin/export_controller_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Chat::ChatController do + describe "#export_messages" do + fab!(:user) { Fabricate(:user) } + fab!(:moderator) { Fabricate(:moderator) } + fab!(:admin) { Fabricate(:admin) } + + it "enqueues the export job and logs into staff actions" do + sign_in(admin) + + post "/chat/admin/export/messages" + + expect(response.status).to eq(204) + + expect(Jobs::ExportCsvFile.jobs.size).to eq(1) + job_data = Jobs::ExportCsvFile.jobs.first["args"].first + expect(job_data["entity"]).to eq("chat_message") + expect(job_data["user_id"]).to eq(admin.id) + + staff_log_entry = UserHistory.last + expect(staff_log_entry.acting_user_id).to eq(admin.id) + expect(staff_log_entry.subject).to eq("chat_message") + end + + it "regular users don't have access" do + sign_in(user) + post "/chat/admin/export/messages" + expect(response.status).to eq(403) + end + + it "moderators don't have access" do + sign_in(moderator) + post "/chat/admin/export/messages" + expect(response.status).to eq(403) + end + end +end