Daily reminder PM for category experts of unanswered questions (#14)

Daily reminder PM for category experts of unanswered questions (#14)

diff --git a/app/jobs/scheduled/remind_admin_of_category_experts_posts_job.rb b/app/jobs/scheduled/remind_admin_of_category_experts_posts_job.rb
new file mode 100644
index 0000000..e66a020
--- /dev/null
+++ b/app/jobs/scheduled/remind_admin_of_category_experts_posts_job.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module CategoryExperts
+  class RemindAdminOfCategoryExpertsPostsJob < ::Jobs::Scheduled
+    every 1.day
+
+    def execute(args = {})
+      return unless SiteSetting.send_category_experts_reminder_pms
+
+      topic_count = questions_with_unapproved_posts_count
+      return if topic_count < 1
+
+      search_url = "#{Discourse.base_url}/search?q=#{CGI::escape("is:category_expert_question with:unapproved_ce_post")}"
+      creator = PostCreator.new(
+        Discourse.system_user,
+        title: I18n.t("category_experts.admin_reminder.title"),
+        raw: I18n.t("category_experts.admin_reminder.body", { topic_count: topic_count, search_url: search_url }),
+        archetype: Archetype.private_message,
+        target_group_names: ["admins", "moderators"],
+        subtype: TopicSubtype.system_message,
+        skip_validations: true
+      )
+      creator.create!
+    end
+
+    def questions_with_unapproved_posts_count
+      DB.query(<<~SQL).count
+            SELECT topics.id
+            FROM topics
+            INNER JOIN topic_custom_fields tc1 ON topics.id = tc1.topic_id
+            INNER JOIN topic_custom_fields tc2 ON topics.id = tc2.topic_id
+            WHERE tc1.name = '#{CategoryExperts::TOPIC_IS_CATEGORY_EXPERT_QUESTION}' AND
+                  tc1.value = 't' AND
+                  tc2.name = '#{CategoryExperts::TOPIC_NEEDS_EXPERT_POST_APPROVAL}' AND
+                  tc2.value = 't'
+            EXCEPT
+            SELECT topics.id
+            FROM topics
+            INNER JOIN topic_custom_fields tc3 ON topics.id = tc3.topic_id
+            WHERE tc3.name = '#{CategoryExperts::TOPIC_EXPERT_POST_GROUP_NAMES}' AND
+                  tc3.value <> '' AND
+                  tc3.value IS NOT NULL
+      SQL
+    end
+  end
+end
diff --git a/app/jobs/scheduled/remind_category_experts_job.rb b/app/jobs/scheduled/remind_category_experts_job.rb
new file mode 100644
index 0000000..12eb647
--- /dev/null
+++ b/app/jobs/scheduled/remind_category_experts_job.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+module CategoryExperts
+  class RemindCategoryExpertsJob < ::Jobs::Scheduled
+    every 1.day
+
+    def execute(args = {})
+      return unless SiteSetting.send_category_experts_reminder_pms
+
+      username_raw_map = {}
+
+      category_custom_fields.each do |category_custom_field|
+        unanswered_count = unanswered_topic_count_for(category_custom_field.category_id)
+        next if unanswered_count < 1
+
+        category = category_custom_field.category
+        group_ids = category_custom_field.value.split("|")
+
+        usernames_in_group_ids(group_ids).each do |username|
+          search_path = "/search?q=#{CGI::escape("##{category.name} is:category_expert_question without:category_expert_post")}"
+          raw = I18n.t("category_experts.experts_reminder.raw_for_category", {
+            topic_count: unanswered_count,
+            category_name: category.name,
+            category_url: category.url,
+            search_url: "#{Discourse.base_url}#{search_path}"
+          })
+          username_raw_map[username] = (username_raw_map[username] || "") + raw
+        end
+      end
+
+      username_raw_map.each { |username, raw| create_message(username, raw) }
+    end
+
+    private
+
+    def category_custom_fields
+      CategoryCustomField
+        .where(name: CategoryExperts::CATEGORY_EXPERT_GROUP_IDS)
+        .where.not(value: nil)
+    end
+
+    def usernames_in_group_ids(group_ids)
+      User
+        .joins(:group_users)
+        .where(group_users: { group_id: group_ids })
+        .pluck(:username)
+        .uniq
+    end
+
+    def create_message(username, raw)
+      creator = PostCreator.new(
+        Discourse.system_user,
+        title: I18n.t("category_experts.experts_reminder.title"),
+        raw: raw,
+        archetype: Archetype.private_message,
+        target_usernames: username,
+        subtype: TopicSubtype.system_message,
+        skip_validations: true
+      )
+      creator.create!
+    end
+
+    def unanswered_topic_count_for(category_id)
+      DB.query(<<~SQL, category_id: category_id).count
+            SELECT topics.id FROM topics
+            INNER JOIN topic_custom_fields tc ON topics.id = tc.topic_id
+            WHERE topics.category_id = :category_id AND
+                  tc.name = '#{CategoryExperts::TOPIC_IS_CATEGORY_EXPERT_QUESTION}' AND
+                  tc.value = 't'
+            EXCEPT
+            SELECT topics.id
+            FROM topics
+            INNER JOIN topic_custom_fields otc ON topics.id = otc.topic_id
+            WHERE (otc.name = '#{CategoryExperts::TOPIC_EXPERT_POST_GROUP_NAMES}' AND
+                  otc.value <> '' AND
+                  otc.value IS NOT NULL)
+            OR (otc.name = '#{CategoryExperts::TOPIC_NEEDS_EXPERT_POST_APPROVAL}' AND
+                  otc.value = 't')
+      SQL
+    end
+  end
+end
diff --git a/assets/javascripts/discourse/connectors/advanced-search-options-below/category-experts-search-fields.hbs b/assets/javascripts/discourse/connectors/advanced-search-options-below/category-experts-search-fields.hbs
index b064c54..0f66ad5 100644
--- a/assets/javascripts/discourse/connectors/advanced-search-options-below/category-experts-search-fields.hbs
+++ b/assets/javascripts/discourse/connectors/advanced-search-options-below/category-experts-search-fields.hbs
@@ -26,6 +26,17 @@
           {{i18n "category_experts.search.question"}}
         </label>
       </section>
+      <section class='field without-category-expert-post-field'>
+        <label>
+          {{input
+            type="checkbox"
+            class="without-category-expert-post"
+            checked=(readonly searchedTerms.withoutCategoryExpertPost)
+            click=(action "onChangeCheckBox" "withoutCategoryExpertPost" "updateWithoutCategoryExpertPost")
+          }}
+          {{i18n "category_experts.search.without_post"}}
+        </label>
+      </section>
     {{/if}}
     {{#if currentUser.staff}}
       <section class='field with-unapproved-ce-post-field'>
diff --git a/assets/javascripts/discourse/connectors/composer-after-save-or-cancel/composer-is-question-checkbox.hbs b/assets/javascripts/discourse/connectors/composer-after-save-or-cancel/composer-is-question-checkbox.hbs
index a16270f..966572c 100644
--- a/assets/javascripts/discourse/connectors/composer-after-save-or-cancel/composer-is-question-checkbox.hbs
+++ b/assets/javascripts/discourse/connectors/composer-after-save-or-cancel/composer-is-question-checkbox.hbs
@@ -1 +1,3 @@
-{{is-question-checkbox model=model}}
+{{#unless site.mobileView}}
+  {{is-question-checkbox model=model}}
+{{/unless}}
diff --git a/assets/javascripts/discourse/connectors/composer-fields/is-category-expert-question.hbs b/assets/javascripts/discourse/connectors/composer-fields/is-category-expert-question.hbs
new file mode 100644
index 0000000..fce8c67
--- /dev/null
+++ b/assets/javascripts/discourse/connectors/composer-fields/is-category-expert-question.hbs
@@ -0,0 +1,3 @@
+{{#if site.mobileView}}
+  {{is-question-checkbox model=model}}
+{{/if}}
diff --git a/assets/javascripts/discourse/initializers/category-experts-search.js b/assets/javascripts/discourse/initializers/category-experts-search.js
index 2312911..7939d6d 100644
--- a/assets/javascripts/discourse/initializers/category-experts-search.js
+++ b/assets/javascripts/discourse/initializers/category-experts-search.js
@@ -3,6 +3,7 @@ import { withPluginApi } from "discourse/lib/plugin-api";
 function initialize(api) {
   const REGEXP_WITH_CATEGORY_EXPERT_RESPONSE = /^with:category_expert_response/gi;

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

GitHub sha: 7f64cf31

This commit appears in #14 which was approved by eviltrout. It was merged by markvanlan.