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"