Rate limit user endorsements (#37)

Rate limit user endorsements (#37)

diff --git a/app/controllers/category_experts_controller.rb b/app/controllers/category_experts_controller.rb
index dd76a12..72fa3bc 100644
--- a/app/controllers/category_experts_controller.rb
+++ b/app/controllers/category_experts_controller.rb
@@ -10,6 +10,7 @@ class CategoryExpertsController < ApplicationController
   before_action :authenticate_and_find_user, only: [:endorse, :endorsable_categories]
 
   def endorse
+    CategoryExperts::EndorsementRateLimiter.new(current_user).performed!
     category_ids = params[:categoryIds]&.reject(&:blank?)
 
     raise Discourse::InvalidParameters if category_ids.blank?
@@ -44,12 +45,15 @@ class CategoryExpertsController < ApplicationController
       guardian.can_see_category?(category) && endorsee_guardian.can_see_category?(category)
     end
 
-    render json: ActiveModel::ArraySerializer.new(
+    render_serialized(
       categories,
-      each_serializer: BasicCategorySerializer,
-      scope: guardian,
+      BasicCategorySerializer,
       root: :categories,
-    ).as_json
+      rest_serializer: true,
+      extras: {
+        remaining_endorsements: CategoryExperts::EndorsementRateLimiter.new(current_user).remaining
+      }
+    )
   end
 
   def approve_post
diff --git a/assets/javascripts/discourse/components/endorsement-checkboxes.js b/assets/javascripts/discourse/components/endorsement-checkboxes.js
index 3235e40..c1b6b49 100644
--- a/assets/javascripts/discourse/components/endorsement-checkboxes.js
+++ b/assets/javascripts/discourse/components/endorsement-checkboxes.js
@@ -1,6 +1,7 @@
 import discourseComputed from "discourse-common/utils/decorators";
 import Component from "@ember/component";
 import { action } from "@ember/object";
+import { lt } from "@ember/object/computed";
 import { later, next } from "@ember/runloop";
 import { ajax } from "discourse/lib/ajax";
 import { popupAjaxError } from "discourse/lib/ajax-error";
@@ -14,6 +15,8 @@ export default Component.extend({
   startingCategoryIds: null,
   showingSuccess: false,
   loading: true,
+  remainingEndorsements: null,
+  outOfEndorsements: lt("remainingEndorsements", 1),
 
   didInsertElement() {
     this._super(...arguments);
@@ -29,6 +32,7 @@ export default Component.extend({
     ajax(`/category-experts/endorsable-categories/${this.user.username}.json`)
       .then((response) => {
         this.setProperties({
+          remainingEndorsements: response.extras.remaining_endorsements,
           categories: response.categories,
           selectedCategoryIds: [...this.startingCategoryIds],
           loading: false,
@@ -48,12 +52,24 @@ export default Component.extend({
       .catch(popupAjaxError);
   },
 
-  @discourseComputed("saving", "selectedCategoryIds", "startingCategoryIds")
-  saveDisabled(saving, categoryIds, startingCategoryIds) {
-    if (saving || !categoryIds) {
-      return;
-    }
-    if (categoryIds.length === 0 && startingCategoryIds.length === 0) {
+  @discourseComputed(
+    "saving",
+    "selectedCategoryIds",
+    "startingCategoryIds",
+    "remainingEndorsements"
+  )
+  saveDisabled(
+    saving,
+    categoryIds,
+    startingCategoryIds,
+    remainingEndorsements
+  ) {
+    if (
+      remainingEndorsements === 0 ||
+      saving ||
+      !categoryIds ||
+      (categoryIds.length === 0 && startingCategoryIds.length === 0)
+    ) {
       return true;
     }
     return !categoryIds.filter((c) => !startingCategoryIds.includes(c)).length;
@@ -61,6 +77,10 @@ export default Component.extend({
 
   @action
   save() {
+    if (this.saveDisabled) {
+      return;
+    }
+
     this.set("saving", true);
 
     ajax(`/category-experts/endorse/${this.user.username}.json`, {
diff --git a/assets/javascripts/discourse/templates/components/endorsement-checkboxes.hbs b/assets/javascripts/discourse/templates/components/endorsement-checkboxes.hbs
index a188d12..941b22e 100644
--- a/assets/javascripts/discourse/templates/components/endorsement-checkboxes.hbs
+++ b/assets/javascripts/discourse/templates/components/endorsement-checkboxes.hbs
@@ -27,10 +27,21 @@
 {{/d-modal-body}}
 
 <div class="modal-footer">
-  {{d-button
-    class="btn-primary category-endorsement-save"
-    action=(action "save")
-    disabled=saveDisabled
-    label="category_experts.endorse"
-  }}
+  {{#if outOfEndorsements}}
+    <div class="alert alert-danger out-of-endorsements-alert">
+      {{i18n "category_experts.out_of_endorsements"}}
+    </div>
+  {{else}}
+    {{d-button
+      class="btn-primary category-endorsement-save"
+      action=(action "save")
+      disabled=saveDisabled
+      label="category_experts.endorse"
+    }}
+    {{#unless currentUser.staff}}
+      <div class="remaining-endorsements-notice">
+        {{i18n "category_experts.remaining_endorsements" count=remainingEndorsements}}
+      </div>
+    {{/unless}}
+  {{/if}}
 </div>
diff --git a/assets/stylesheets/common.scss b/assets/stylesheets/common.scss
index 5289734..3a5067d 100644
--- a/assets/stylesheets/common.scss
+++ b/assets/stylesheets/common.scss
@@ -1,12 +1,12 @@
 .category-expert-existing-endorsements {
-  font-size: $font-down-2;
+  font-size: var(--font-down-2);
 
   .category-expert-endorse-edit {
     display: inline-block;
     width: unset;
     min-width: unset;
     padding: 2px;
-    color: $tertiary;
+    color: var(--tertiary);
     cursor: pointer;
     font-weight: bold;
     text-decoration: underline;
@@ -45,9 +45,14 @@
   margin: 2px 0 0px 3px;
 }
 
+.endorse-user-modal .remaining-endorsements-notice {
+  color: var(--secondary-medium);
+  font-size: var(--font-down-1);
+}
+
 .endorsement-successful {
   text-align: center;
-  color: $success;
+  color: var(--success);
   font-size: 5em;
   line-height: 1em;
 }
@@ -55,18 +60,18 @@
 .time-gap + .topic-post article.category-expert-post {
   .topic-body,
   .topic-avatar {
-    border-top: 4px solid $success;
+    border-top: 4px solid var(--success);
   }
 }
 article.category-expert-post {
   .topic-body,
   .topic-avatar {
-    border-top: 4px solid $success;
+    border-top: 4px solid var(--success);
   }
 }
 
 .category-expert-indicator .d-icon {
-  color: $success;
+  color: var(--success);
 }
 
 .post-controls .approve-category-expert-post .d-button-label {
@@ -76,23 +81,23 @@ article.category-expert-post {
 .topic-list-category-expert-tags > a,
 .topic-list-category-expert-needs-approval,
 .topic-list-category-expert-question {
-  font-size: $font-down-2;
+  font-size: var(--font-down-2);
   padding: 0.3em 0.5em;
-  background: $success-low;
-  color: $primary-high;
+  background: var(--success-low);
+  color: var(--primary-high);
   border-radius: 1em;
   margin-left: 4px;
 }
 
 .topic-list-category-expert-needs-approval {
-  background: $highlight-low;
+  background: var(--highlight-low);
 }
 .topic-list-category-expert-question {
-  background: $tertiary-low;
+  background: var(--tertiary-low);
 }
 
 ul.category-experts-post-admin-menu-btn li {
-  border-top: 1px solid $primary-low !important;
+  border-top: 1px solid var(--primary-low) !important;
 }
 
 .category-experts-search-fields label {
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index a6020e9..85f1e21 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -9,6 +9,8 @@ en:
       accepting_endorsements: "Currently accepting user endorsements"
       accepting_questions: "Currently accepting questions for category experts"
       endorse: "Endorse"
+      out_of_endorsements: "You have used all your endorsements for the day."
+      remaining_endorsements: "%{count} endorsements remaining today"
       existing_endorsements: "Endorsed in %{count} categories."
       edit: "Edit"
       approve: "Approve"
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index d111074..5472c90 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -6,6 +6,11 @@ en:

[... diff too long, it was truncated ...]

GitHub sha: ec59adca

This commit appears in #37 which was approved by Falco and pmusaraj. It was merged by markvanlan.