diff --git a/.rubocop.yml b/.rubocop.yml index b72e370438..684d9ce1d6 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -13,6 +13,9 @@ AllCops: - app/models/**/* - app/controllers/publishers_controller.rb - app/controllers/publishers/registrations_controller.rb + - app/controllers/admin/publisher_notes_controller.rb + - app/controllers/admin/publishers_controller.rb + - app/controllers/admin/publishers/publisher_status_updates_controller.rb Exclude: - bin/**/* - db/**/* diff --git a/app/assets/stylesheets/admin/main.scss b/app/assets/stylesheets/admin/main.scss index e7d93603ac..bff892bd07 100644 --- a/app/assets/stylesheets/admin/main.scss +++ b/app/assets/stylesheets/admin/main.scss @@ -14,3 +14,4 @@ // Admin pages @import "pages/**/*"; +@import "tooltip"; diff --git a/app/assets/stylesheets/admin/style.scss b/app/assets/stylesheets/admin/style.scss index 56e06da319..094ced1374 100644 --- a/app/assets/stylesheets/admin/style.scss +++ b/app/assets/stylesheets/admin/style.scss @@ -282,3 +282,54 @@ a.logo span { border: 1px solid #eee; color: #ddd; } + +.user-avatar { + margin: 4px; + width: 42px; + height: 42px; + border: solid 1px rgba(0, 0, 0, 0.2); + border-radius: 50%; + overflow: hidden; + + svg { + width: 40px; + height: 40px; + opacity: 0.8; + filter: brightness(5) grayscale(1) + drop-shadow(0px 0px 1px rgba(0, 0, 0, 0.3)); + } +} + +.user-avatar-dropdown { + border: solid 1px rgba(0, 0, 0, 0.2); + border-radius: 50%; + display: inline-block; + overflow: hidden; + height: 27px; + width: 27px; + margin-right: 8px; + + img { + width: 25px; + height: 25px; + opacity: 0.8; + filter: brightness(5) grayscale(1) + drop-shadow(0px 0px 1px rgba(0, 0, 0, 0.3)); + } +} + +// Overwrite the dropdown +.tribute-container { + border-radius: 5px; + box-shadow: 0 12px 16px rgba(0, 0, 0, 0.3); + background: white; + border: 1px solid #999; +} + +.tribute-container ul { + background: white !important; + margin-top: 0 !important; +} +.tribute-container li { + border-bottom: 1px solid #e9e9e9; +} diff --git a/app/assets/stylesheets/admin/tooltip.scss b/app/assets/stylesheets/admin/tooltip.scss new file mode 100644 index 0000000000..12f3b160de --- /dev/null +++ b/app/assets/stylesheets/admin/tooltip.scss @@ -0,0 +1,56 @@ +// Taken from https://chrisbracco.com/a-simple-css-tooltip/ + +/* Add this attribute to the element that needs a tooltip */ +[data-tooltip] { + position: relative; + z-index: 2; + cursor: pointer; +} + +/* Hide the tooltip content by default */ +[data-tooltip]:before, +[data-tooltip]:after { + visibility: hidden; + opacity: 0; + pointer-events: none; +} + +/* Position tooltip above the element */ +[data-tooltip]:before { + position: absolute; + bottom: 150%; + left: 50%; + margin-bottom: 5px; + margin-left: -80px; + padding: 7px; + width: 180px; + border-radius: 3px; + background-color: hsla(0, 0%, 20%, 0.9); + color: #fff; + content: attr(data-tooltip); + text-align: center; + font-size: 14px; + line-height: 1.2; +} + +/* Triangle hack to make tooltip look like a speech bubble */ +[data-tooltip]:after { + position: absolute; + bottom: 150%; + left: 50%; + margin-left: -5px; + width: 0; + border-top: 5px solid hsla(0, 0%, 20%, 0.9); + border-right: 5px solid transparent; + border-left: 5px solid transparent; + content: " "; + font-size: 0; + line-height: 0; +} + +/* Show tooltip content on hover */ +[data-tooltip]:hover:before, +[data-tooltip]:hover:after { + visibility: visible; + opacity: 1; +} diff --git a/app/assets/stylesheets/mailer.scss b/app/assets/stylesheets/mailer.scss index 5f2336aae2..edff0c4d73 100644 --- a/app/assets/stylesheets/mailer.scss +++ b/app/assets/stylesheets/mailer.scss @@ -1,4 +1,4 @@ -@import 'colors'; +@import "colors"; body, .header, .content, @@ -70,13 +70,13 @@ p { padding: 12px 0; display: table; .btn-primary { - color: #FFFFFF; - text-decoration: none; - text-align: center; - border-radius: 30px; - height: 30px; - background-color: $primaryAccent; - padding: 12px 24px; + color: #ffffff; + text-decoration: none; + text-align: center; + border-radius: 30px; + height: 30px; + background-color: $primaryAccent; + padding: 12px 24px; } } .promo--referral-link-container { @@ -116,8 +116,6 @@ p { } } } - - } } @@ -151,3 +149,37 @@ p { width: 24px; } } + +.user-avatar { + margin: 4px; + width: 30px; + height: 30px; + border: solid 1px rgba(0, 0, 0, 0.2); + border-radius: 50%; + overflow: hidden; + + svg { + width: 30px; + height: 30px; + opacity: 0.8; + filter: brightness(5) grayscale(1) + drop-shadow(0px 0px 1px rgba(0, 0, 0, 0.3)); + } +} + +.d-flex { + display: flex; +} +.mr-2 { + margin-right: 0.5rem; +} + +.my-2 { + margin-bottom: 0.5rem !important; + margin-top: 0.5rem !important; +} + +.mx-2 { + margin-left: 0.5rem !important; + margin-right: 0.5rem !important; +} diff --git a/app/controllers/admin/publisher_notes_controller.rb b/app/controllers/admin/publisher_notes_controller.rb new file mode 100644 index 0000000000..d123d5871f --- /dev/null +++ b/app/controllers/admin/publisher_notes_controller.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Admin + class PublisherNotesController < AdminController + before_action :authorize, except: :create + + EMAIL = "@brave.com" + + def create + publisher = Publisher.find(params[:publisher_id]) + + note = PublisherNote.new( + publisher: publisher, + created_by: current_user, + note: note_params[:note], + ) + note.thread_id = note_params[:thread_id] if note_params[:thread_id].present? + + if note.save + email_tagged_users(note) + + if note_params[:thread_id].present? + created_by = note.thread.created_by + if current_user != created_by && note.note.exclude?("@" + created_by.email.sub(EMAIL, '')) + InternalMailer.tagged_in_note( + tagged_user: note.thread.created_by, + note: note + ).deliver_later + end + end + + redirect_to(admin_publisher_path(publisher.id)) + else + redirect_to admin_publisher_path(publisher.id), flash: { alert: note.errors.full_messages.join(',') } + end + end + + def update + if @note.update(note_params) + email_tagged_users(@note) + + redirect_to admin_publisher_path(id: params[:publisher_id]), flash: { success: "Successfully updated comment" } + else + redirect_to admin_publisher_path(id: params[:publisher_id]), flash: { alert: @note.errors.full_messages.join(',') } + end + end + + def destroy + publisher = @note.publisher + if @note.comments.any? + redirect_to admin_publisher_path(publisher), flash: { alert: "Can't delete a note that has comments." } + else + @note.destroy + + redirect_to admin_publisher_path(publisher) + end + end + + private + + def email_tagged_users(publisher_note) + publisher_note.note.scan(/\@(\w*)/).uniq.each do |mention| + # Some reason the regex likes to put an array inside array + mention = mention[0] + publisher = Publisher.where("email LIKE ?", mention + EMAIL).first + InternalMailer.tagged_in_note(tagged_user: publisher, note: publisher_note).deliver_later if publisher.present? + end + end + + def authorize + @note = PublisherNote.find(params[:id]) + raise unless @note.created_by == current_user + end + + def note_params + params.require(:publisher_note).permit(:note, :thread_id) + end + end +end diff --git a/app/controllers/admin/publishers/publisher_status_updates_controller.rb b/app/controllers/admin/publishers/publisher_status_updates_controller.rb index 5658bcdc7f..948a3cf6da 100644 --- a/app/controllers/admin/publishers/publisher_status_updates_controller.rb +++ b/app/controllers/admin/publishers/publisher_status_updates_controller.rb @@ -1,15 +1,13 @@ class Admin::Publishers::PublisherStatusUpdatesController < Admin::PublishersController def index get_publisher - @navigation_view = Views::Admin::NavigationView.new(@publisher).as_json.merge({ navbarSelection: "Dashboard"}).to_json + @navigation_view = Views::Admin::NavigationView.new(@publisher).as_json.merge({ navbarSelection: "Dashboard" }).to_json @publisher_status_updates = @publisher.status_updates end def create - @publisher.status_updates.create(status: params[:publisher_status]) - if params[:note].present? - @publisher.notes.create(note: params[:note], created_by_id: current_publisher.id) - end + note = @publisher.notes.create(note: params[:note], created_by_id: current_publisher.id) + @publisher.status_updates.create(status: params[:publisher_status], publisher_note: note) @publisher.reload # TODO: Send emails for other manual status updates, and send email without creating a status update diff --git a/app/controllers/admin/publishers_controller.rb b/app/controllers/admin/publishers_controller.rb index c68671b07c..5523907b69 100644 --- a/app/controllers/admin/publishers_controller.rb +++ b/app/controllers/admin/publishers_controller.rb @@ -18,24 +18,38 @@ def index @publishers = @publishers.where(search_sql, search_query: search_query) end - @publishers = @publishers.suspended if params[:suspended].present? + if params[:status].present? && PublisherStatusUpdate::ALL_STATUSES.include?(params[:status]) + # Effectively sanitizes the users input + method = PublisherStatusUpdate::ALL_STATUSES.detect { |x| x == params[:status] } + @publishers = @publishers.send(method) + end + + if params[:role].present? + @publishers = @publishers.where(role: params[:role]) + end + if params[:two_factor_authentication_removal].present? @publishers = @publishers.joins(:two_factor_authentication_removal).distinct end @publishers = @publishers.where.not(email: nil).or(@publishers.where.not(pending_email: nil)) # Don't include deleted users @publishers = @publishers.group(:id).paginate(page: params[:page]) + + respond_to do |format| + format.json { render json: @publishers.to_json({ methods: :avatar_color }) } + format.html {} + end end def show @publisher = Publisher.find(params[:id]) - @navigation_view = Views::Admin::NavigationView.new(@publisher).as_json.merge({ navbarSelection: "Dashboard"}).to_json + @navigation_view = Views::Admin::NavigationView.new(@publisher).as_json.merge({ navbarSelection: "Dashboard" }).to_json @potential_referral_payment = @publisher.most_recent_potential_referral_payment - @note = PublisherNote.new + @current_user = current_user end def edit @publisher = Publisher.find(params[:id]) - @navigation_view = Views::Admin::NavigationView.new(@publisher).as_json.merge({ navbarSelection: "Dashboard"}).to_json + @navigation_view = Views::Admin::NavigationView.new(@publisher).as_json.merge({ navbarSelection: "Dashboard" }).to_json end def update @@ -96,10 +110,6 @@ def get_publisher @publisher = Publisher.find(params[:publisher_id] || params[:id]) end - def publisher_create_note_params - params.require(:publisher_note).permit(:publisher, :note) - end - def admin_approval_channel_params params.require(:channel_id) end diff --git a/app/controllers/api/v1/stats/publishers_controller.rb b/app/controllers/api/v1/stats/publishers_controller.rb index 23304980aa..55f6d020d9 100644 --- a/app/controllers/api/v1/stats/publishers_controller.rb +++ b/app/controllers/api/v1/stats/publishers_controller.rb @@ -67,6 +67,29 @@ def channel_uphold_and_email_verified_signups_per_day render(json: fill_in_blank_dates(result).to_json, status: 200) end + def channel_and_kyc_uphold_and_email_verified_signups_per_day + sql = + """ + select p.created_at::date, count(*) + from ( + select distinct publishers.* + from publishers + inner join channels + on channels.publisher_id = publishers.id and channels.verified = true + inner join uphold_connections + on uphold_connections.publisher_id = publishers.id + where role = 'publisher' + and uphold_connections.uphold_verified = true + and uphold_connections.is_member = true + and email is not null + ) as p + group by p.created_at::date + order by p.created_at::date + """ + result = ActiveRecord::Base.connection.execute(sql).values + render(json: fill_in_blank_dates(result).to_json, status: 200) + end + def totals if params[:up_to_date].present? up_to_date = Date.parse(params[:up_to_date]) diff --git a/app/helpers/admin/publishers_helper.rb b/app/helpers/admin/publishers_helper.rb index b42f27fbf9..366419baba 100644 --- a/app/helpers/admin/publishers_helper.rb +++ b/app/helpers/admin/publishers_helper.rb @@ -3,8 +3,25 @@ module PublishersHelper def publisher_status(publisher) link_to( publisher.last_status_update.present? ? publisher.last_status_update.status : "active", - admin_publisher_publisher_status_updates_path(publisher) + admin_publisher_publisher_status_updates_path(publisher), + class: status_badge_class(publisher.last_status_update.present? ? publisher.last_status_update.status : "active") ) end + + def status_badge_class(status) + label = case status + when PublisherStatusUpdate::SUSPENDED + "badge-danger" + when PublisherStatusUpdate::LOCKED + "badge-warning" + when PublisherStatusUpdate::NO_GRANTS + "badge-dark" + when PublisherStatusUpdate::ACTIVE + "badge-success" + else + "badge-secondary" + end + "badge #{label}" + end end end diff --git a/app/helpers/admin_helper.rb b/app/helpers/admin_helper.rb index 94898fdfed..abab3a2d5f 100644 --- a/app/helpers/admin_helper.rb +++ b/app/helpers/admin_helper.rb @@ -7,6 +7,36 @@ def sort_link(column, title = nil) link_to "#{title} ".html_safe, {column: column, direction: direction}.merge(params.permit(:type, :search)) end + def set_mentions(note) + formatted_note = note.lines.map do |line| + line = line.split(" ").map do |word| + if word.starts_with?("@") + publisher = Publisher.where("email LIKE ?", "#{word[1..-1]}@brave.com").first + # Assuming the administrator is a brave.com email address :) + word = link_to("@#{publisher.name}", admin_publisher_url(publisher)) if publisher.present? + end + + # Format for link + if word.starts_with?("http://") || word.starts_with?("https://") + # If the link is another publisher + if word.include?('admin/publishers/') + publisher_id = word.sub("#{root_url}admin/publishers/", "") + publisher = Publisher.find_by(id: publisher_id) + word = link_to(publisher.name, admin_publisher_url(publisher)) if publisher + end + + word = link_to(word) + end + + word + end + + line.join(" ") + "\r\n" + end + + formatted_note.join + end + def no_data_default(value) value || "--" end diff --git a/app/javascript/packs/admin.js b/app/javascript/packs/admin.js index 9cd9b9f53b..eb881c99fa 100644 --- a/app/javascript/packs/admin.js +++ b/app/javascript/packs/admin.js @@ -1,8 +1,8 @@ -import 'utils/request'; -import 'admin/dashboard/index'; -import 'admin/stats/index'; -import 'admin/dashboard/unattached_promo_registration' -import Rails from 'rails-ujs'; +import "utils/request"; +import "admin/dashboard/index"; +import "admin/stats/index"; +import "admin/dashboard/unattached_promo_registration"; +import Rails from "rails-ujs"; /* * Override the default way Rails picks an href for a data-method link. This @@ -31,10 +31,12 @@ Rails.href = function Rails_href_override(element) { }; Rails.start(); -document.addEventListener('DOMContentLoaded', function(){ - let sidebarToggles = document.getElementsByClassName("sidebar-toggle"); +document.addEventListener( + "DOMContentLoaded", + function() { + let sidebarToggles = document.getElementsByClassName("sidebar-toggle"); for (var i = 0; i < sidebarToggles.length; i++) { - sidebarToggles[i].addEventListener('click', function (event) { + sidebarToggles[i].addEventListener("click", function(event) { var item = event.target || event.srcElement; // If the clicked element doesn't have the right selector, bail document.activeElement.blur(); @@ -44,16 +46,18 @@ document.addEventListener('DOMContentLoaded', function(){ let icon = item.children[0]; // Toggle the menu - let element = item.parentElement.querySelector('.sub-menu'); - if(element.style.display === 'none') { + let element = item.parentElement.querySelector(".sub-menu"); + if (element.style.display === "none") { icon.classList.remove("fa-chevron-down"); icon.classList.add("fa-chevron-up"); - element.style.display = ''; + element.style.display = ""; } else { icon.classList.remove("fa-chevron-up"); icon.classList.add("fa-chevron-down"); - element.style.display = 'none'; + element.style.display = "none"; } }); } - }, false); + }, + false +); diff --git a/app/javascript/packs/tribute.js b/app/javascript/packs/tribute.js new file mode 100644 index 0000000000..30e6d71c37 --- /dev/null +++ b/app/javascript/packs/tribute.js @@ -0,0 +1,39 @@ +import Tribute from "tributejs"; +import "tributejs/dist/tribute.css"; +import Avatar from "../views/admin/components/userNavbar/components/TopNav/Avatar.svg"; + +document.addEventListener("DOMContentLoaded", function() { + if (window.location.href.indexOf("admin/publishers/") !== -1) { + fetch("/admin/publishers?role=admin") + .then(function(response) { + return response.json(); + }) + .then(function(publishers) { + const list = publishers.map(p => { + return { + key: p.name, + value: p.email.split("@")[0], + avatarColor: p.avatar_color + }; + }); + + var tribute = new Tribute({ + values: list, + menuItemTemplate: function(item) { + return ( + `
` + + `
` + + `` + + `
` + + `${item.string}` + + `
` + ); + } + }); + + tribute.attach(document.querySelectorAll(".note-form")); + }); + } +}); diff --git a/app/javascript/views/admin/components/userNavbar/components/TopNav/TopNavStyle.ts b/app/javascript/views/admin/components/userNavbar/components/TopNav/TopNavStyle.ts index 9e722ea269..0b2ebbebae 100644 --- a/app/javascript/views/admin/components/userNavbar/components/TopNav/TopNavStyle.ts +++ b/app/javascript/views/admin/components/userNavbar/components/TopNav/TopNavStyle.ts @@ -115,6 +115,11 @@ export const Status = styled.div` ` background-color: #FCCD56; `} + ${(props: Partial) => + props.status === "no_grants" && + ` + background-color: #343a40; + `} `; export const StatusLink = styled.a` diff --git a/app/jobs/cache_browser_channels_json_job.rb b/app/jobs/cache_browser_channels_json_job.rb index 3ee600cd08..20ef760676 100644 --- a/app/jobs/cache_browser_channels_json_job.rb +++ b/app/jobs/cache_browser_channels_json_job.rb @@ -3,7 +3,7 @@ class CacheBrowserChannelsJsonJob < ApplicationJob def perform channels_json = JsonBuilders::ChannelsJsonBuilder.new.build - Rails.cache.write('browser_channels_json', channels_json) - Rails.logger.info("CacheBrowserChannelsJsonJob updated the cached browser channels json.") + result = Rails.cache.write('browser_channels_json', channels_json) + Rails.logger.info("CacheBrowserChannelsJsonJob updated the cached browser channels json. Result: #{result}") end end diff --git a/app/jobs/delete_publisher_channel_job.rb b/app/jobs/delete_publisher_channel_job.rb index 874993642b..233d6a0d8a 100644 --- a/app/jobs/delete_publisher_channel_job.rb +++ b/app/jobs/delete_publisher_channel_job.rb @@ -3,7 +3,8 @@ class DeletePublisherChannelJob < ApplicationJob def perform(channel_id:) @channel = Channel.find(channel_id) - raise "Can't remove a channel that is contesting another a channel." if @channel.verification_pending? + publisher = @channel.publisher + raise "Can't remove a channel that is contesting another a channel." if @channel.verification_pending? && !publisher.registered_for_2fa_removal? # If channel is being contested, approve the channel which will also delete if @channel.contested_by_channel.present? diff --git a/app/jobs/two_factor_authentication_removal_job.rb b/app/jobs/two_factor_authentication_removal_job.rb index e95fdf469e..dd751594b8 100644 --- a/app/jobs/two_factor_authentication_removal_job.rb +++ b/app/jobs/two_factor_authentication_removal_job.rb @@ -21,7 +21,7 @@ def perform DeletePublisherChannelJob.perform_now(channel_id: channel.id) end end - publisher.uphold_connection.disconnect_uphold + publisher.uphold_connection&.disconnect_uphold PublisherWalletDisconnector.new(publisher: publisher).perform publisher.status_updates.create(status: PublisherStatusUpdate::LOCKED) two_factor_authentication_removal.update(removal_completed: true) diff --git a/app/mailers/internal_mailer.rb b/app/mailers/internal_mailer.rb index c0c44c1066..58196d56ee 100644 --- a/app/mailers/internal_mailer.rb +++ b/app/mailers/internal_mailer.rb @@ -1,5 +1,6 @@ class InternalMailer < ApplicationMailer add_template_helper(PublishersHelper) + add_template_helper(AdminHelper) layout 'internal_mailer' # Someone attempted to verify restricted channel and completed the automated steps. @@ -14,4 +15,15 @@ def channel_verification_approval_required(channel:) subject: " #{@channel.details.publication_title} Verification approval required" ) end + + def tagged_in_note(tagged_user:, note:) + return unless tagged_user.admin? + + @note = note + + mail( + to: tagged_user.email, + subject: "New reply or mention in note on publisher #{note.publisher.name || note.publisher.email}" + ) + end end diff --git a/app/models/publisher.rb b/app/models/publisher.rb index fb4f4eda2e..ac9abae23c 100644 --- a/app/models/publisher.rb +++ b/app/models/publisher.rb @@ -1,3 +1,5 @@ +require 'digest/md5' + class Publisher < ApplicationRecord has_paper_trail only: [:name, :email, :pending_email, :phone_normalized, :last_sign_in_at, :default_currency, :role, :excluded_from_payout] self.per_page = 20 @@ -75,14 +77,13 @@ class Publisher < ApplicationRecord scope :not_admin, -> { where.not(role: ADMIN) } scope :partner, -> { where(role: PARTNER) } scope :not_partner, -> { where.not(role: PARTNER) } - scope :suspended, -> { - joins(:status_updates). - where('publisher_status_updates.created_at = - (SELECT MAX(publisher_status_updates.created_at) - FROM publisher_status_updates - WHERE publisher_status_updates.publisher_id = publishers.id)'). - where("publisher_status_updates.status = 'suspended'") - } + + scope :created, -> { filter_status(PublisherStatusUpdate::CREATED) } + scope :onboarding, -> { filter_status(PublisherStatusUpdate::ONBOARDING) } + scope :suspended, -> { filter_status(PublisherStatusUpdate::SUSPENDED) } + scope :locked, -> { filter_status(PublisherStatusUpdate::LOCKED) } + scope :deleted, -> { filter_status(PublisherStatusUpdate::DELETED) } + scope :no_grants, -> { filter_status(PublisherStatusUpdate::NO_GRANTS) } scope :not_suspended, -> { where.not(id: suspended) @@ -92,6 +93,28 @@ class Publisher < ApplicationRecord joins(:channels).where('channels.verified = true').distinct } + def self.filter_status(status) + joins(:status_updates). + where('publisher_status_updates.created_at = + (SELECT MAX(publisher_status_updates.created_at) + FROM publisher_status_updates + WHERE publisher_status_updates.publisher_id = publishers.id)'). + where("publisher_status_updates.status = ?", status) + end + + # Because the status_updates wasn't backfilled we also include those who have no status to be "Active" + def self.active + joins('LEFT OUTER JOIN publisher_status_updates ON publisher_status_updates.publisher_id = publishers.id'). + where('publisher_status_updates.created_at = + ( + SELECT MAX(publisher_status_updates.created_at) + FROM publisher_status_updates + WHERE publisher_status_updates.publisher_id = publishers.id + ) OR + publisher_status_updates.publisher_id is NULL'). + where("publisher_status_updates.status = ? OR publisher_status_updates.status is NULL", PublisherStatusUpdate::ACTIVE) + end + def self.statistical_totals(up_to_date: 1.day.from_now) # TODO change this { @@ -164,15 +187,25 @@ def email_verified? email.present? end + # Silly method for showing a color for people's avatar + def avatar_color + Digest::MD5.hexdigest(email || pending_email)[0...6] + end + # Public: Show history of publisher's notes and statuses sorted by the created time # # Returns an array of PublisherNote and PublisherStatusUpdate def history # Create hash with created_at time as the key # Then we can merge and sort by the key to get history - notes = self.notes.map { |n| { n.created_at => n } } + notes = self.notes.where(thread_id: nil) status = status_updates.map { |s| { s.created_at => s } } + statuses_with_notes = status_updates.select { |s| s.publisher_note_id.present? }.map(&:publisher_note_id) + notes = notes.to_a.delete_if { |n| statuses_with_notes.include?(n.id) } + + notes.map! { |n| { n.created_at => n } } + combined = notes + status combined = combined.sort { |x, y| x.keys.first <=> y.keys.first }.reverse @@ -260,6 +293,10 @@ def register_for_2fa_removal ) end + def registered_for_2fa_removal? + two_factor_authentication_removal.present? + end + # Remove when new dashboard is finished def in_new_ui_whitelist? partner? diff --git a/app/models/publisher_note.rb b/app/models/publisher_note.rb index 3ef0ef0edd..4100383e67 100644 --- a/app/models/publisher_note.rb +++ b/app/models/publisher_note.rb @@ -2,4 +2,10 @@ class PublisherNote < ApplicationRecord belongs_to :publisher belongs_to :created_by, class_name: "Publisher", foreign_key: :created_by_id validates :created_by, presence: true + + # Enable self-references, which allows threading + belongs_to :thread, class_name: "PublisherNote" + has_many :comments, class_name: "PublisherNote", foreign_key: "thread_id" + + validates :note, presence: true, allow_blank: false end diff --git a/app/models/publisher_status_update.rb b/app/models/publisher_status_update.rb index 17ac0460bf..754b939785 100644 --- a/app/models/publisher_status_update.rb +++ b/app/models/publisher_status_update.rb @@ -10,6 +10,7 @@ class PublisherStatusUpdate < ApplicationRecord ALL_STATUSES = [CREATED, ONBOARDING, ACTIVE, SUSPENDED, LOCKED, NO_GRANTS, DELETED].freeze belongs_to :publisher + belongs_to :publisher_note validates :status, presence: true, :inclusion => { in: ALL_STATUSES } diff --git a/app/views/admin/publisher_notes/_form.html.slim b/app/views/admin/publisher_notes/_form.html.slim new file mode 100644 index 0000000000..fdf5f693b3 --- /dev/null +++ b/app/views/admin/publisher_notes/_form.html.slim @@ -0,0 +1,12 @@ += form_for [:admin, publisher, note] do |f| + - thread_id ||= nil + - if thread_id.present? + = f.hidden_field :thread_id, value: thread_id + = f.text_area(:note, rows: "4", class: "form-control note-form", placeholder:"Add a comment...") + .mt-3 + = f.submit(note.id.present? ? "Update note" : "Add note", class: 'btn btn-primary mr-1') + + - if note.id.present? || thread_id.present? + a.btn.btn-dark id="cancel_#{note.id}_#{thread_id}" href="#" Cancel + + javascript: diff --git a/app/views/admin/publishers/_note.html.slim b/app/views/admin/publishers/_note.html.slim index bb4c1f9065..7c7889140b 100644 --- a/app/views/admin/publishers/_note.html.slim +++ b/app/views/admin/publishers/_note.html.slim @@ -1,8 +1,96 @@ -.note.rounded-box - .note-header - .box - = note.created_by.name - .box - = note.created_at.strftime("%B %d, %Y %k:%M %Z") - hr - = note.note +- condense ||= nil + +.note.mt-4 id="container_#{note.id}" + = csrf_meta_tags + + .d-flex + - unless condense + .avatar.mr-2 + .user-avatar style="background: ##{note.created_by.avatar_color};"= render "avatar_svg" + .content.w-100 + - unless condense + .note-header + / The name of the person + strong= note.created_by.name + / Bullet point + small.text-muted.mx-2 • + + / Time it was created + span.date data-tooltip=note.created_at.strftime("%B %d, %Y %k:%M %Z") + = time_ago_in_words(note.created_at) + span= " ago" + + / Time it was updated + - if note.created_at != note.updated_at + small.text-muted.mx-2 • + span data-tooltip=note.updated_at.strftime("%B %d, %Y %k:%M %Z") edited + + / Hidden field for showing the edit + div id="update_#{note.id}" class="d-none my-3" + = render partial: 'admin/publisher_notes/form', locals: { note: note, publisher: publisher } + + / Actual content of the note + div id="content_#{note.id}" class="#{condense ? "" : "my-3" }" + = simple_format(set_mentions(note.note), sanitize: true) + + .links + / Reply button + a.text-dark id="reply_#{note.id}" href="#" Reply + + / Optionally show the edit and delete if the user created the note + - if current_user == note.created_by + small.text-muted.mx-2 • + a.text-dark id="edit_#{note.id}" href="#" Edit + - unless condense + small.text-muted.mx-2 • + = link_to 'Delete', admin_publisher_publisher_note_path(publisher_id: note.publisher.id, id: note.id), method: :delete, data: { confirm: 'Are you sure you want do delete this note?' }, class: 'text-dark' + + / The reply box + div id="new_#{note.id}" class="d-none mt-3" + = render partial: 'admin/publisher_notes/form', locals: { note: PublisherNote.new, publisher: publisher, thread_id: note.id } + + javascript: + document.addEventListener('DOMContentLoaded', function() { + var reply = document.getElementById("reply_#{note.id}"); + var edit = document.getElementById("edit_#{note.id}"); + // This is "clever" which means it needs explaining. + // Basically the edit and new forms are the same thing, except for a parameter called thread_id + // thread_id is the comment that users are replying to, if it's a new comment then it will have the thread id + // otherwise there will be a note.id which is the note that we're editing. + // we can find which one by setting the id of the cancel button to "cancel_{note.id}_{thread_id}" + var cancelReply = document.getElementById("cancel__#{note.id}"); + var cancelEdit = document.getElementById("cancel_#{note.id}_"); + + function toggle(id) { + const replyBox = document.getElementById(id) + replyBox.classList.toggle('d-none'); + replyBox.querySelector("textarea").focus() + + return false; + } + + function toggle_reply() { + toggle("new_#{note.id}") + return false; + } + + function toggle_edit() { + toggle("update_#{note.id}") + // Hide the previous content + document.getElementById("content_#{note.id}").classList.toggle('d-none'); + return false; + } + + reply.onclick = toggle_reply; + cancelReply.onclick = toggle_reply; + + if (edit) { + edit.onclick = toggle_edit; + cancelEdit.onclick = toggle_edit; + } + }); + + / the sub comments + .border-left.pl-2 + - note.comments&.each do |comment| + = render partial: 'note', locals: { note: comment, publisher: publisher, current_user: current_user } diff --git a/app/views/admin/publishers/_status.html.slim b/app/views/admin/publishers/_status.html.slim index 77e6e65732..337444b10e 100644 --- a/app/views/admin/publishers/_status.html.slim +++ b/app/views/admin/publishers/_status.html.slim @@ -1,8 +1,16 @@ -.note.rounded-box - .note-header - .box - = "Status change" - .box - = note.created_at.strftime("%B %d, %Y %k:%M %Z") - hr - = note.status +.note.mt-4 + strong + = "Status change" + - if status.publisher_note.present? + = " by #{status.publisher_note.created_by.name}" + + small.text-muted.mx-2 • + span.date data-tooltip=status.created_at.strftime("%B %d, %Y %k:%M %Z") + = time_ago_in_words(status.created_at) + span= " ago" + div + .mt-3 + span class=status_badge_class(status.status) + = status.status + - if status.publisher_note.present? + = render partial: 'note', locals: { note: status.publisher_note, publisher: publisher, current_user: current_user, condense: true } diff --git a/app/views/admin/publishers/index.html.slim b/app/views/admin/publishers/index.html.slim index 8540d17396..4e0071a7d7 100644 --- a/app/views/admin/publishers/index.html.slim +++ b/app/views/admin/publishers/index.html.slim @@ -7,10 +7,10 @@ div.row .input-group = text_field_tag(:q, params[:q], class:'form-control') .input-group-btn - = submit_tag("Search", class: 'btn btn-default') - label - = check_box_tag('suspended', 1, params[:suspended].present?, class: 'checkbox') - = "Suspended" + = submit_tag("Search", class: 'btn btn-default', name: nil) + .form-inline.mt-1 + = select_tag(:status, options_for_select(PublisherStatusUpdate::ALL_STATUSES, params[:status]), include_blank: "Filter by status" , class: "form-control mr-2", onchange:"this.form.submit()") + = select_tag(:role, options_for_select(Publisher::ROLES, params[:role]), include_blank: "Filter by role" , class: "form-control", onchange: "this.form.submit()") br div.panel-body div.adv-table @@ -36,7 +36,7 @@ div.row td = publisher.notes.order(created_at: :desc).first&.note - if publisher.two_factor_authentication_removal td = publisher.two_factor_authentication_removal.total_time_remaining - - else + - else td = "N/A" td = publisher.created_at.strftime('%B %d, %Y') td = distance_of_time_in_words(publisher.last_sign_in_at, Time.now) if publisher.last_sign_in_at.present? diff --git a/app/views/admin/publishers/publisher_status_updates/index.html.slim b/app/views/admin/publishers/publisher_status_updates/index.html.slim index ebd6fdcc46..9d0762f1ba 100644 --- a/app/views/admin/publishers/publisher_status_updates/index.html.slim +++ b/app/views/admin/publishers/publisher_status_updates/index.html.slim @@ -1,16 +1,36 @@ = render partial: 'admin/shared/publisher_header', locals: { navigation_view: @navigation_view } + h5.admin-header Change current status for #{@publisher.name} +- if @publisher.suspended? + .my-3 + p.alert.alert-warning + = "If you are planning on unsuspending this user please provide evidence and justification for your actions. " + br + br + = "If you believe this user is a potential outlier please contact the appropriate group and alert them." +- else + .my-3 + p.alert.alert-warning + = "If you are planning on suspending this user please provide evidence and justification for your actions. This may come in the form of excel, git, or a reasonable explaination of the users misdeeds." + br + br + = "Please view more information about the publisher guidelines here." + br + = link_to "https://community.brave.com/t/a-note-to-publishers/48733", "https://community.brave.com/t/a-note-to-publishers/48733" + = form_for(@publisher, url: admin_publisher_publisher_status_updates_path(@publisher.id), method: :create) do |f| - - label_tag(:publisher_id, @publisher.id) - = select_tag(:publisher_status, options_for_select(PublisherStatusUpdate::ALL_STATUSES), class: "form-control") - label - = check_box_tag("send_email", true) - = " Send email (suspended only)" - br - = label_tag(:note_label, "Include a note: ") - = text_area_tag(:note, '', class: 'form-control') - br + .form-group + = label_tag(:status_label, "Status") + = select_tag(:publisher_status, options_for_select(PublisherStatusUpdate::ALL_STATUSES, @publisher.last_status_update), class: "form-control") + + .form-group + = label_tag(:note_label, "Include a note: ") + = text_area_tag(:note, '', class: 'form-control', rows: 5, required: true) + .form-group + label + = check_box_tag("send_email", true) + = " Send email (suspended only)" = f.submit("Update Status", class: 'btn btn-primary') hr @@ -25,5 +45,7 @@ div.panel-body tbody - @publisher.status_updates.each do |status_update| tr.gradeX - td = status_update.status + td + span class=status_badge_class(status_update.status) + = status_update.status td = status_update.created_at diff --git a/app/views/admin/publishers/show.html.slim b/app/views/admin/publishers/show.html.slim index d006d62f5b..d3ce272420 100644 --- a/app/views/admin/publishers/show.html.slim +++ b/app/views/admin/publishers/show.html.slim @@ -33,9 +33,6 @@ hr .db-info-row .db-field = "Organization:" .db-value = link_to partner.organization, admin_organization_path(partner.organization) - .db-info-row - .db-field = "Publisher status:" - .db-value = publisher_status(@publisher) - if @publisher.wallet.uphold_account_status.present? .db-info-row .db-field = "Uphold status:" @@ -158,15 +155,19 @@ hr hr h3.admin-header = "Notes" + = render partial: 'admin/publisher_notes/form', locals: { note: PublisherNote.new, publisher: @publisher } + hr + #notes-section - #create-note.rounded-box - = form_for @note, { method: :post, url: create_note_admin_publishers_path } do |f| - = f.text_area(:note, id: "create-note-content") - = f.hidden_field(:publisher, :value => @publisher.id) - = f.submit("Add note", class: 'btn btn-primary') + #create-note - @publisher.history.each do |h| - if h.is_a?(PublisherNote) - = render partial: 'note', locals: { note: h } + = render partial: 'note', locals: { note: h, publisher: @publisher, current_user: @current_user} - elsif h.is_a?(PublisherStatusUpdate) - = render partial: 'status', locals: { note: h } + = render partial: 'status', locals: { status: h, publisher: @publisher, current_user: @current_user} + hr + + += javascript_pack_tag 'tribute' += stylesheet_pack_tag 'tribute' diff --git a/app/views/admin/shared/_sidebar.html.slim b/app/views/admin/shared/_sidebar.html.slim index d86e61304d..aadf1b4324 100644 --- a/app/views/admin/shared/_sidebar.html.slim +++ b/app/views/admin/shared/_sidebar.html.slim @@ -37,7 +37,7 @@ aside = fa_icon "chevron-down" = link_to "Publishers", admin_publishers_path ul.sub-menu style="#{params[:controller] == 'admin/publishers' ? "" : "display:none"} " - = nav_link "Suspended Publishers", admin_publishers_path(suspended: true) + = nav_link "Suspended Publishers", admin_publishers_path(status: PublisherStatusUpdate::SUSPENDED) ul.sub-menu style="#{params[:controller] == 'admin/publishers' ? "" : "display:none"} " = nav_link "2FA Removals", admin_publishers_path(two_factor_authentication_removal: true) = nav_link "FAQs", admin_faq_categories_path diff --git a/app/views/internal_mailer/tagged_in_note.html.slim b/app/views/internal_mailer/tagged_in_note.html.slim new file mode 100644 index 0000000000..7cbd28fbe9 --- /dev/null +++ b/app/views/internal_mailer/tagged_in_note.html.slim @@ -0,0 +1,35 @@ +- if @note.thread_id + div style="border-left: .25em solid #dfe2e5; padding: 0 1em; color: #6a737d;" + div + = simple_format(set_mentions(@note.thread.note), sanitize: true) + ="— #{@note.thread.created_by.name}" + +div style="margin-top: 1rem;" + .d-flex + .avatar.mr-2 + .user-avatar style="background: ##{@note.created_by.avatar_color};"= render "application/avatar_svg" + .w-100 + .note-header + / The name of the person + strong= @note.created_by.name + / Bullet point + small.text-muted.mx-2 • + + span.date + = "Created #{@note.created_at.strftime("%B %d, %Y %k:%M %Z")}" + + / Time it was updated + - if @note.created_at != @note.updated_at + small.text-muted.mx-2 • + = "Updated #{@note.updated_at.strftime("%B %d, %Y %k:%M %Z")}" + + .my-2 + = simple_format(set_mentions(@note.note), sanitize: true) +div + = link_to "View this note", admin_publisher_url(@note.publisher), target: '_blank', style: "font-size: small;" + +div style="color: #666; font-size: small;" + ="—" + br + ="You are receiving this because you were mentioned." + diff --git a/app/views/layouts/admin.html.slim b/app/views/layouts/admin.html.slim index 863a1256d8..fd439efeae 100644 --- a/app/views/layouts/admin.html.slim +++ b/app/views/layouts/admin.html.slim @@ -10,7 +10,7 @@ html = render 'admin/shared/sidebar' section id='main-content' section id='wrapper' - .notifications + .notifications.mb-2 - if flash[:alert] .alert.alert-warning.flash= flash[:alert] - if flash[:notice] diff --git a/config/environments/production.rb b/config/environments/production.rb index 9696678295..e8091f51b3 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -1,3 +1,5 @@ +require 'sentry-raven' + Rails.application.configure do # Verifies that versions and hashed value of the package contents in the project's package.json config.webpacker.check_yarn_integrity = false @@ -66,7 +68,18 @@ # Use a different cache store in production. # config.cache_store = :mem_cache_store - config.cache_store = :redis_cache_store, { url: Rails.application.secrets[:redis_url] } + config.cache_store = :redis_cache_store, { + url: Rails.application.secrets[:redis_url], + connect_timeout: 30, # Defaults to 20 seconds + read_timeout: 10, # Defaults to 1 second + write_timeout: 10, # Defaults to 1 second + + error_handler: -> (method:, returning:, exception:) { + # Report errors to Sentry as warnings + Raven.capture_exception exception, level: 'warning', + tags: { method: method, returning: returning } + } + } # Use a real queuing backend for Active Job (and separate queues per environment) # config.active_job.queue_adapter = :resque diff --git a/config/environments/staging.rb b/config/environments/staging.rb index 9f3701dcac..66c30f1bb7 100644 --- a/config/environments/staging.rb +++ b/config/environments/staging.rb @@ -1,3 +1,5 @@ +require 'sentry-raven' + Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. # Allow images from CDN @@ -59,7 +61,18 @@ # Use a different cache store in production. # config.cache_store = :mem_cache_store - config.cache_store = :redis_cache_store, { url: Rails.application.secrets[:redis_url] } + config.cache_store = :redis_cache_store, { + url: Rails.application.secrets[:redis_url], + connect_timeout: 30, # Defaults to 20 seconds + read_timeout: 10, # Defaults to 1 second + write_timeout: 10, # Defaults to 1 second + + error_handler: -> (method:, returning:, exception:) { + # Report errors to Sentry as warnings + Raven.capture_exception exception, level: 'warning', + tags: { method: method, returning: returning } + } + } # Use a real queuing backend for Active Job (and separate queues per environment) # config.active_job.queue_adapter = :resque diff --git a/config/routes.rb b/config/routes.rb index b2c46ba55b..aac64686fd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -133,6 +133,7 @@ get :email_verified_signups_per_day get :channel_and_email_verified_signups_per_day get :channel_uphold_and_email_verified_signups_per_day + get :channel_and_kyc_uphold_and_email_verified_signups_per_day get :javascript_enabled_usage get :totals end @@ -166,9 +167,9 @@ collection do patch :approve_channel get :statement - post :create_note get :cancel_two_factor_authentication_removal end + resources :publisher_notes resources :reports resources :publisher_status_updates, controller: 'publishers/publisher_status_updates' end diff --git a/config/webpacker.yml b/config/webpacker.yml index d00719333c..dfd3526fda 100644 --- a/config/webpacker.yml +++ b/config/webpacker.yml @@ -49,14 +49,13 @@ development: use_local_ip: false quiet: false headers: - 'Access-Control-Allow-Origin': '*' + "Access-Control-Allow-Origin": "*" watch_options: ignored: /node_modules/ - test: <<: *default - compile: true + compile: false # Compile test packs to a separate directory public_output_path: packs-test diff --git a/db/migrate/20190515170634_add_threads_to_publisher_notes.rb b/db/migrate/20190515170634_add_threads_to_publisher_notes.rb new file mode 100644 index 0000000000..67b3b87a1b --- /dev/null +++ b/db/migrate/20190515170634_add_threads_to_publisher_notes.rb @@ -0,0 +1,5 @@ +class AddThreadsToPublisherNotes < ActiveRecord::Migration[5.2] + def change + add_reference :publisher_notes, :thread, type: :uuid, index: true + end +end diff --git a/db/migrate/20190524205525_add_note_id_to_publisher_status_update.rb b/db/migrate/20190524205525_add_note_id_to_publisher_status_update.rb new file mode 100644 index 0000000000..7ce9ba43e2 --- /dev/null +++ b/db/migrate/20190524205525_add_note_id_to_publisher_status_update.rb @@ -0,0 +1,5 @@ +class AddNoteIdToPublisherStatusUpdate < ActiveRecord::Migration[5.2] + def change + add_reference :publisher_status_updates, :publisher_note, type: :uuid, index: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 6bccdd9ed8..1c42d0b480 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2019_04_22_195852) do +ActiveRecord::Schema.define(version: 2019_05_24_205525) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -326,15 +326,19 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "created_by_id", null: false + t.uuid "thread_id" t.index ["created_by_id"], name: "index_publisher_notes_on_created_by_id" t.index ["publisher_id"], name: "index_publisher_notes_on_publisher_id" + t.index ["thread_id"], name: "index_publisher_notes_on_thread_id" end create_table "publisher_status_updates", id: :uuid, default: -> { "uuid_generate_v4()" }, force: :cascade do |t| t.uuid "publisher_id", null: false t.string "status", null: false t.datetime "created_at", null: false + t.uuid "publisher_note_id" t.index ["publisher_id", "created_at"], name: "index_publisher_status_updates_on_publisher_id_and_created_at" + t.index ["publisher_note_id"], name: "index_publisher_status_updates_on_publisher_note_id" end create_table "publishers", id: :uuid, default: -> { "uuid_generate_v4()" }, force: :cascade do |t| diff --git a/package.json b/package.json index 2011be780e..c7c9cd039a 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "react-avatar-editor": "^11.0.4", "react-dom": "^16.6.3", "styled-components": "^4.0.3", + "tributejs": "^3.7.1", "ts-loader": "^5.3.0", "typescript": "^3.1.6" }, diff --git a/test/controllers/admin/publisher_notes_controller_test.rb b/test/controllers/admin/publisher_notes_controller_test.rb new file mode 100644 index 0000000000..65ef5b21d8 --- /dev/null +++ b/test/controllers/admin/publisher_notes_controller_test.rb @@ -0,0 +1,241 @@ +require "test_helper" +require "shared/mailer_test_helper" +require "webmock/minitest" + +module Admin + class PublisherNotesControllerTest < ActionDispatch::IntegrationTest + include Devise::Test::IntegrationHelpers + include ActionMailer::TestHelper + + before do + admin = publishers(:admin) + sign_in admin + end + + describe '#create' do + describe 'when the user comments on a publisher' do + let(:note_params) { { publisher_note: { note: "this is a new note" } } } + let(:subject) { post admin_publisher_publisher_notes_path(publishers(:verified).id), params: note_params } + + before do + subject + end + + it 'creates a top level note' do + assert_equal publishers(:verified).notes.count, 1 + assert_equal publishers(:verified).notes.first.note, "this is a new note" + end + + it 'redirects to the publisher page' do + assert_redirected_to admin_publisher_path(publishers(:verified)) + end + end + + describe 'when the note contains a mention' do + let(:note_params) { { publisher_note: { note: "@hello testing this" } } } + let(:subject) { post admin_publisher_publisher_notes_path(publishers(:verified).id), params: note_params } + + it 'enqueued the email' do + assert_enqueued_emails(1) { subject } + end + + it 'sends to the user' do + perform_enqueued_jobs { subject } + + email = ActionMailer::Base.deliveries.find do |message| + message.to.first == "hello@brave.com" + end + + refute_nil email + end + end + + describe 'when the note is being replied' do + describe 'it replies to admins' do + let(:note_params) do + { + publisher_note: { + note: "reply note", + thread_id: publisher_notes(:note).id + } + } + end + + let(:subject) { post admin_publisher_publisher_notes_path(publishers(:verified).id), params: note_params } + + it 'emails the user who is being replied to' do + perform_enqueued_jobs { subject } + + email = ActionMailer::Base.deliveries.find do |message| + message.to.first == publisher_notes(:note).created_by.email + end + + refute_nil email + end + end + + describe 'it does not send an email to non-administrators' do + let(:note_params) do + { + publisher_note: { + note: "reply note", + thread_id: publisher_notes(:non_admin_note).id + } + } + end + + let(:subject) { post admin_publisher_publisher_notes_path(publishers(:verified).id), params: note_params } + + it 'emails the user who is being replied to' do + perform_enqueued_jobs { subject } + + email = ActionMailer::Base.deliveries.find do |message| + message.to.first == publisher_notes(:non_admin_note).created_by.email + end + + assert_nil email + end + end + end + + describe 'when there is an error' do + let(:note_params) do + { + publisher_note: { + note: "", + thread_id: publisher_notes(:note).id + } + } + end + + let(:subject) { post admin_publisher_publisher_notes_path(publishers(:verified).id), params: note_params } + + before do + subject + end + + it 'does not create a note' do + assert_equal flash[:alert], "Note can't be blank" + end + + it 'redirects to the publisher page' do + assert_redirected_to admin_publisher_path(publishers(:verified)) + end + end + end + + describe '#update' do + describe 'when the admin is updating' do + let(:subject) do + patch admin_publisher_publisher_note_path( + id: publisher_notes(:admin_note).id, + publisher_id: publishers(:verified).id + ), params: note_params + end + + describe 'when the note contains a mention' do + let(:note_params) do + { publisher_note: { note: "@hello test" } } + end + + it 'emails the users' do + perform_enqueued_jobs { subject } + + email = ActionMailer::Base.deliveries.find do |message| + message.to.first == "hello@brave.com" + end + + refute_nil email + end + + it 'updates the note' do + subject + assert_equal PublisherNote.find(publisher_notes(:admin_note).id).note, "@hello test" + end + end + + describe 'when there is an error' do + let(:note_params) do + { publisher_note: { note: "" } } + end + + before { subject } + + it 'does not update the note' do + assert_equal PublisherNote.find(publisher_notes(:admin_note).id).note, publisher_notes(:admin_note).note + end + + it 'does not create a note' do + assert_equal flash[:alert], "Note can't be blank" + end + + it 'redirects to the publisher page' do + assert_redirected_to admin_publisher_path(publishers(:verified)) + end + end + end + + describe 'when the user was not the one who created the note' do + let(:subject) do + patch admin_publisher_publisher_note_path( + id: publisher_notes(:note).id, + publisher_id: publishers(:verified).id + ), params: note_params + end + + it 'raises an exception' do + assert_raises { subject } + end + end + end + + describe 'delete' do + describe 'when the note is a thread' do + let(:subject) do + delete admin_publisher_publisher_note_path( + id: publisher_notes(:thread_note).id, + publisher_id: publishers(:verified).id + ) + end + + before { subject } + + it 'does not allow the user to delete' do + assert_equal flash[:alert], "Can't delete a note that has comments." + end + end + + describe 'when the user was not the one who created the note' do + let(:subject) do + delete admin_publisher_publisher_note_path( + id: publisher_notes(:note).id, + publisher_id: publishers(:verified).id + ), params: note_params + end + + it 'raises an exception' do + assert_raises { subject } + end + end + + describe 'when the note was created by the user' do + let(:subject) do + delete admin_publisher_publisher_note_path( + id: publisher_notes(:child_note).id, + publisher_id: publishers(:just_notes).id + ) + end + + before { subject } + + it 'redirects to the publisher page' do + assert_redirected_to admin_publisher_path(publishers(:just_notes).id) + end + + it 'deletes the note' do + refute PublisherNote.find_by(id: publisher_notes(:child_note).id) + end + end + end + end +end diff --git a/test/controllers/admin/publishers_controller_test.rb b/test/controllers/admin/publishers_controller_test.rb index f6c2a2fe67..3aeb4362cb 100644 --- a/test/controllers/admin/publishers_controller_test.rb +++ b/test/controllers/admin/publishers_controller_test.rb @@ -85,7 +85,7 @@ def stub_verification_public_file(channel, body: nil, status: 200) end it 'only shows suspended when suspended filter is on' do - get admin_publishers_path, params: { suspended: "1" } + get admin_publishers_path, params: { status: "suspended" } publishers = controller.instance_variable_get("@publishers") diff --git a/test/controllers/api/v1/stats/publishers_controller_test.rb b/test/controllers/api/v1/stats/publishers_controller_test.rb index b7b2d97353..c8f83bddc4 100644 --- a/test/controllers/api/v1/stats/publishers_controller_test.rb +++ b/test/controllers/api/v1/stats/publishers_controller_test.rb @@ -75,6 +75,25 @@ class Api::V1::Stats::PublishersControllerTest < ActionDispatch::IntegrationTest [1.days.ago.to_date.to_s, 0], [0.days.ago.to_date.to_s, 0] ], JSON.parse(response.body) + + get "/api/v1/stats/publishers/channel_and_kyc_uphold_and_email_verified_signups_per_day", headers: { "HTTP_AUTHORIZATION" => "Token token=fake_api_auth_token" } + + assert_equal 200, response.status + assert_equal [ + [6.days.ago.to_date.to_s, Publisher.distinct.joins(:channels).joins(:uphold_connection) + .where(created_at: 6.days.ago.beginning_of_day..6.days.ago.end_of_day, + 'uphold_connections.uphold_verified': true, 'uphold_connections.is_member': true, + role: Publisher::PUBLISHER) + .where.not(email: nil) + .where(channels: { verified: true }) + .count], + [5.days.ago.to_date.to_s, 0], + [4.days.ago.to_date.to_s, 0], + [3.days.ago.to_date.to_s, 0], + [2.days.ago.to_date.to_s, 0], + [1.days.ago.to_date.to_s, 0], + [0.days.ago.to_date.to_s, 0] + ], JSON.parse(response.body) end test "totals endpoint has content" do diff --git a/test/fixtures/publisher_notes.yml b/test/fixtures/publisher_notes.yml index c3b4aed6b9..14c5032738 100644 --- a/test/fixtures/publisher_notes.yml +++ b/test/fixtures/publisher_notes.yml @@ -12,7 +12,32 @@ comment_after_suspended: &comment_after_suspended created_by: default note: + publisher: just_notes + note: "Should be suspended" + created_at: "2018-12-04" + created_by: just_notes + +non_admin_note: publisher: just_notes note: "Should be suspended" created_at: "2018-12-04" created_by: default + +admin_note: + publisher: just_notes + note: "This is an admin note" + created_at: "2018-12-04" + created_by: admin + +thread_note: + publisher: just_notes + note: "This is parent note" + created_at: "2018-12-04" + created_by: admin + +child_note: + publisher: just_notes + note: "This is child note" + created_at: "2018-12-04" + created_by: admin + thread: thread_note diff --git a/test/fixtures/publishers.yml b/test/fixtures/publishers.yml index d590d5dabb..74979704e1 100644 --- a/test/fixtures/publishers.yml +++ b/test/fixtures/publishers.yml @@ -39,6 +39,7 @@ just_notes: two_factor_prompted_at: "<%= 1.day.ago %>" agreed_to_tos: "<%= 1.day.ago %>" visible: true + role: 'admin' verified_totp_only: email: "alice_totp@verified.org" diff --git a/test/fixtures/uphold_connections.yml b/test/fixtures/uphold_connections.yml index 681654605d..071f20f1d3 100644 --- a/test/fixtures/uphold_connections.yml +++ b/test/fixtures/uphold_connections.yml @@ -19,6 +19,7 @@ created_connection: connected_connection: uphold_verified: true publisher: uphold_connected + is_member: true verified_no_currency: uphold_verified: true diff --git a/test/mailers/previews/publisher_mailer_preview.rb b/test/mailers/previews/publisher_mailer_preview.rb index 9eb3472521..a39a6d64d7 100644 --- a/test/mailers/previews/publisher_mailer_preview.rb +++ b/test/mailers/previews/publisher_mailer_preview.rb @@ -80,4 +80,7 @@ def confirm_email_change PublisherMailer.confirm_email_change(publisher) end + def tagged_in_note + InternalMailer.tagged_in_note(tagged_user: Publisher.where(role: 'admin').first, note: PublisherNote.where("note LIKE ?", "%@%").first) + end end diff --git a/test/tasks/send_grid_refresh_test.rb b/test/tasks/send_grid_refresh_test.rb index 8684e46773..c0446727b0 100644 --- a/test/tasks/send_grid_refresh_test.rb +++ b/test/tasks/send_grid_refresh_test.rb @@ -16,6 +16,8 @@ class SendGridRefreshTest < ActiveJob::TestCase where.not(email: "alice_totp@verified.org"). where.not(email: "alice@completed.org"). where.not(email: "alice@spud.com"). + where.not(email: "hello@brave.com"). + where.not(email: "only@notes.org"). where.not(email: "alice2@verified.org"). where.not(email: "fred@vglobal.org"). where.not(email: "fred@small.org").delete_all diff --git a/test/test_helper.rb b/test/test_helper.rb index 5aa651b2b3..e8337131a9 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -4,6 +4,7 @@ require File.expand_path("../../config/environment", __FILE__) require "rails/test_help" +require "webpacker" require "selenium/webdriver" require "minitest/rails/capybara" require "webmock/minitest" @@ -17,6 +18,7 @@ Minitest::Rails::TestUnit = Rails::TestUnit end +Webpacker.compile Sidekiq::Testing.fake! diff --git a/yarn.lock b/yarn.lock index 63600408fa..d2f40f8613 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1806,11 +1806,6 @@ babel-plugin-transform-react-jsx@^6.24.1: babel-plugin-syntax-jsx "^6.8.0" babel-runtime "^6.22.0" -babel-plugin-transform-react-remove-prop-types@^0.4.24: - version "0.4.24" - resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-remove-prop-types/-/babel-plugin-transform-react-remove-prop-types-0.4.24.tgz#f2edaf9b4c6a5fbe5c1d678bfb531078c1555f3a" - integrity sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA== - babel-polyfill@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-polyfill/-/babel-polyfill-6.26.0.tgz#379937abc67d7895970adc621f284cd966cf2153" @@ -9603,6 +9598,11 @@ tr46@^1.0.1: dependencies: punycode "^2.1.0" +tributejs@^3.7.1: + version "3.7.1" + resolved "https://registry.yarnpkg.com/tributejs/-/tributejs-3.7.1.tgz#af6529358810022bc90650b3ff2094344591a1dd" + integrity sha512-GsRnD7w7u4BgrzYm/9ZskuVpVTVtY83WrLA5PtBl2oOYTQFGVVVHS7vAGwgmO0iStXzEa6LlVUiB4dsEUYo2ug== + trim-newlines@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613"