From a634189ae6c719e4086994fa975fb70adce7622e Mon Sep 17 00:00:00 2001
From: Jerry Huang <34140255+qwef@users.noreply.github.com>
Date: Tue, 20 Feb 2024 13:56:42 -0500
Subject: [PATCH] Add Creator Sentiment Email task (#38787)

* Add new email function and send email to creator

* remove debug stuff

* add tests, address comments

* actually add tests

* Update src/metabase/email/messages.clj

Co-authored-by: Noah Moss <32746338+noahmoss@users.noreply.github.com>

* address comments

---------

Co-authored-by: Noah Moss <32746338+noahmoss@users.noreply.github.com>
---
 .clj-kondo/hooks/metabase/models/setting.clj  |   1 +
 src/metabase/analytics/stats.clj              |  18 +--
 .../email/creator_sentiment_email.mustache    |  38 ++++++
 src/metabase/email/messages.clj               |  33 ++++++
 .../public_settings/premium_features.clj      |   2 +-
 .../task/creator_sentiment_emails.clj         | 110 ++++++++++++++++++
 .../task/creator_sentiment_emails_test.clj    |  93 +++++++++++++++
 7 files changed, 279 insertions(+), 16 deletions(-)
 create mode 100644 src/metabase/email/creator_sentiment_email.mustache
 create mode 100644 src/metabase/task/creator_sentiment_emails.clj
 create mode 100644 test/metabase/task/creator_sentiment_emails_test.clj

diff --git a/.clj-kondo/hooks/metabase/models/setting.clj b/.clj-kondo/hooks/metabase/models/setting.clj
index 4c64701a4bb..9e2aa9fdbb0 100644
--- a/.clj-kondo/hooks/metabase/models/setting.clj
+++ b/.clj-kondo/hooks/metabase/models/setting.clj
@@ -92,6 +92,7 @@
      metabot-prompt-generator-token-limit
      multi-setting-read-only
      notification-link-base-url
+     no-surveys
      num-metabot-choices
      openai-api-key
      openai-available-models
diff --git a/src/metabase/analytics/stats.clj b/src/metabase/analytics/stats.clj
index 1f80f79971b..93ceb3b147e 100644
--- a/src/metabase/analytics/stats.clj
+++ b/src/metabase/analytics/stats.clj
@@ -16,21 +16,9 @@
    [metabase.integrations.google :as google]
    [metabase.integrations.slack :as slack]
    [metabase.models
-    :refer [Card
-            Collection
-            Dashboard
-            DashboardCard
-            Database
-            Field
-            Metric
-            PermissionsGroup
-            Pulse
-            PulseCard
-            PulseChannel
-            QueryCache
-            Segment
-            Table
-            User]]
+    :refer [Card Collection Dashboard DashboardCard Database Field Metric
+            PermissionsGroup Pulse PulseCard PulseChannel QueryCache Segment
+            Table User]]
    [metabase.models.humanization :as humanization]
    [metabase.public-settings :as public-settings]
    [metabase.util :as u]
diff --git a/src/metabase/email/creator_sentiment_email.mustache b/src/metabase/email/creator_sentiment_email.mustache
new file mode 100644
index 00000000000..98be30bcbd0
--- /dev/null
+++ b/src/metabase/email/creator_sentiment_email.mustache
@@ -0,0 +1,38 @@
+{{> metabase/email/_header }}
+  <div class="card-container" style="color: {{colorTextMedium}};">
+    {{#self-hosted}}
+    <p style="font-weight: 500; font-size: 0.875em; line-height: 1.375em; color: {{colorTextMedium}};">
+      <i>(This email is sent directly from {{self-hosted}}, it doesn't go through any external services)</i>
+    </p>
+    {{/self-hosted}}
+
+    <p style="font-weight: 500; line-height: 1.375em; color: {{colorTextMedium}};">
+      Hi {{first-name}},
+    </p>
+
+    <p style="font-weight: 500; line-height: 1.375em; color: {{colorTextMedium}};">
+      We at Metabase are working hard to make the experience of people like you, who create lots of content (questions, dashboards, etc).
+    </p>
+
+    <p style="font-weight: 600; line-height: 1.375em; color: {{colorTextMedium}};">
+      We’d like to know how we’re doing.<br>Can you spare 30 seconds to fill out this NPS survey?
+    </p>
+
+    <div style="text-align: center;">
+      <a
+        href="{{link}}"
+        style="display: inline-block; padding: 0.75rem 1.125em; background-color: {{applicationColor}}; color: #FFF; border-radius: 6px; font-size: 0.875em; font-weight: 700; text-decoration: none;"
+      >
+        Answer Now
+      </a>
+    </div>
+
+    <p style="font-weight: 500; line-height: 1.375em; color: {{colorTextMedium}};">
+      Thank you,
+    </p>
+
+    <p style="font-weight: 500; line-height: 1.375em; color: {{colorTextMedium}};">
+      - The Metabase Product Team
+    </p>
+  </div>
+{{> metabase/email/_footer }}
diff --git a/src/metabase/email/messages.clj b/src/metabase/email/messages.clj
index c99c2ab70b6..80cb761f1c0 100644
--- a/src/metabase/email/messages.clj
+++ b/src/metabase/email/messages.clj
@@ -291,6 +291,39 @@
                :message      (stencil/render-file "metabase/email/follow_up_email" context)}]
     (email/send-message! email)))
 
+(defn- creator-sentiment-blob
+  "Create a blob of instance/user data to be sent to the creator sentiment survey."
+  [instance-data created_at num_dashboards num_questions num_models]
+  (-> {:instance instance-data
+       :user {:created_at created_at
+              :num_dashboards num_dashboards
+              :num_questions num_questions
+              :num_models num_models}}
+      json/generate-string
+      .getBytes
+      codecs/bytes->b64-str))
+
+(defn send-creator-sentiment-email!
+  "Format and send an email to the system admin following up on the installation."
+  [{:keys [email created_at first_name num_dashboards num_questions num_models]} instance-data]
+  {:pre [(u/email? email)]}
+  (let [blob    (when (public-settings/anon-tracking-enabled)
+                  (creator-sentiment-blob instance-data created_at num_dashboards num_questions num_models))
+        context (merge (common-context)
+                       {:emailType  "notification"
+                        :logoHeader true
+                        :first-name first_name
+                        :link (if (public-settings/anon-tracking-enabled)
+                                (str "https://metabase.com/feedback/creator?context=" blob)
+                                "https://metabase.com/feedback/creator")}
+                       (when-not (premium-features/is-hosted?)
+                         {:self-hosted (public-settings/site-url)}))
+        message {:subject      "Metabase would love your take on something"
+                 :recipients   [email]
+                 :message-type :html
+                 :message      (stencil/render-file "metabase/email/creator_sentiment_email" context)}]
+    (email/send-message! message)))
+
 (defn- make-message-attachment [[content-id url]]
   {:type         :inline
    :content-id   content-id
diff --git a/src/metabase/public_settings/premium_features.clj b/src/metabase/public_settings/premium_features.clj
index 842b9a1b655..ef86a464543 100644
--- a/src/metabase/public_settings/premium_features.clj
+++ b/src/metabase/public_settings/premium_features.clj
@@ -249,7 +249,7 @@
         (cached-logger (premium-embedding-token) e)
         #{}))))
 
-(defn- has-any-features?
+(defn has-any-features?
   "True if we have a valid premium features token with ANY features."
   []
   (boolean (seq (*token-features*))))
diff --git a/src/metabase/task/creator_sentiment_emails.clj b/src/metabase/task/creator_sentiment_emails.clj
new file mode 100644
index 00000000000..b8242222d92
--- /dev/null
+++ b/src/metabase/task/creator_sentiment_emails.clj
@@ -0,0 +1,110 @@
+(ns metabase.task.creator-sentiment-emails
+  (:require
+   [clojurewerkz.quartzite.jobs :as jobs]
+   [clojurewerkz.quartzite.schedule.cron :as cron]
+   [clojurewerkz.quartzite.triggers :as triggers]
+   [java-time.api :as t]
+   [metabase.analytics.snowplow :as snowplow]
+   [metabase.config :as config]
+   [metabase.db :as mdb]
+   [metabase.driver.sql.query-processor :as sql.qp]
+   [metabase.email :as email]
+   [metabase.email.messages :as messages]
+   [metabase.models.setting :as setting]
+   [metabase.public-settings :as public-settings]
+   [metabase.public-settings.premium-features :as premium-features]
+   [metabase.task :as task]
+   [metabase.util.i18n :refer [deferred-tru]]
+   [metabase.util.log :as log]
+   [toucan2.core :as t2]))
+
+(set! *warn-on-reflection* true)
+
+(setting/defsetting ^:private no-surveys
+  (deferred-tru "Enable or disable creator sentiment emails")
+  :type       :boolean
+  :default    false
+  :visibility :internal
+  :audit      :getter)
+
+(defn- fetch-creators
+  "Fetch the creators who are eligible for a creator sentiment email. Which are users who, in the past 2 months:
+    - Created at least 10 questions total
+    - Created at least 2 SQL questions
+    - Created at least 1 dashboard
+    - Only admins if whitelabeling is enabled"
+  [has-whitelabelling?]
+  (t2/query {:select [[:u.email :email]
+                      [:u.date_joined :created_at]
+                      [:u.first_name :first_name]
+                      [[:count [:distinct [:case [:= :d.archived false] :d.id]]] :num_dashboards]
+                      [[:count [:distinct [:case [:and [:= :rc.type "question"] [:= :rc.archived false]] :rc.id]]] :num_questions]
+                      [[:count [:distinct [:case [:and [:= :rc.type "model"] [:= :rc.archived false]] :rc.id]]] :num_models]]
+             :from [[:core_user :u]]
+             :join [[:report_card :rc] [:= :rc.creator_id :u.id]
+                    [:report_dashboard :d] [:= :d.creator_id :u.id]]
+             :where [:and
+                     [:>= :rc.created_at (sql.qp/add-interval-honeysql-form (mdb/db-type) :%now -2 :month)]
+                     [:>= :d.created_at (sql.qp/add-interval-honeysql-form (mdb/db-type) :%now -2 :month)]
+                     [:= :u.is_active true]
+                     [:= :u.type "personal"]
+                     (when has-whitelabelling? [:= :u.is_superuser true])]
+             :group-by [:u.id]
+             :having [:and
+                      [:>= [:count [:distinct :rc.id]] 10]
+                      [:>= [:count [:distinct [:case [:= :rc.query_type "native"] :rc.id]]] 2]
+                      [:>= [:count [:distinct :d.id]] 1]]}))
+
+(defn fetch-plan-info
+  "Figure out what plan this Metabase instance is on."
+  []
+  (cond
+    (and config/ee-available? (premium-features/is-hosted?) (premium-features/has-any-features?)) "pro-cloud/enterprise-cloud"
+    (and config/ee-available? (premium-features/is-hosted?) (not (premium-features/has-any-features?))) "starter"
+    (and config/ee-available? (not (premium-features/is-hosted?))) "pro-self-hosted/enterprise-self-hosted"
+    (not config/ee-available?) "oss"
+    :else "unknown"))
+
+(defn- fetch-instance-data []
+  {:created_at     (snowplow/instance-creation)
+   :plan           (fetch-plan-info)
+   :verison        (config/mb-version-info :tag)
+   :num_users      (t2/count :model/User :is_active true, :type "personal")
+   :num_dashboards (t2/count :model/Dashboard :archived false)
+   :num_questions  (t2/count :model/Card :archived false :type "question")
+   :num_models     (t2/count :model/Card :archived false :type "model")})
+
+(defn- send-creator-sentiment-emails!
+  "Send an email to the instance admin following up on their experience with Metabase thus far."
+  []
+  ;; we need access to email AND the instance must have surveys enabled.
+  (when (and (email/email-configured?)
+             (not (no-surveys)))
+    (let [instance-data (when (public-settings/anon-tracking-enabled) (fetch-instance-data))
+          creators (fetch-creators (premium-features/enable-whitelabeling?))
+          month (- (.getValue (t/month)) 1)]
+      (doseq [creator creators]
+        ;; Send the email if the creator's email hash matches the current month
+        (when (= (-> creator :email hash (mod 12)) month)
+          (try
+            (messages/send-creator-sentiment-email! creator instance-data)
+            (catch Throwable e
+              (log/error "Problem sending creator sentiment email:" e))))))))
+
+(jobs/defjob ^{:doc "Sends out a monthly survey to a portion of the creators."} CreatorSentimentEmail [_]
+  (send-creator-sentiment-emails!))
+
+(def ^:private creator-sentiment-emails-job-key     "metabase.task.creator-sentiment-emails.job")
+(def ^:private creator-sentiment-emails-trigger-key "metabase.task.creator-sentiment-emails.trigger")
+
+(defmethod task/init! ::SendCreatorSentimentEmails [_]
+  (let [job     (jobs/build
+                 (jobs/of-type CreatorSentimentEmail)
+                 (jobs/with-identity (jobs/key creator-sentiment-emails-job-key)))
+        trigger (triggers/build
+                 (triggers/with-identity (triggers/key creator-sentiment-emails-trigger-key))
+                 (triggers/start-now)
+                 (triggers/with-schedule
+                   ;; Fire at 9:15am on the 1st day of every month
+                   (cron/cron-schedule "0 15 9 1 * ?")))]
+    (task/schedule-task! job trigger)))
diff --git a/test/metabase/task/creator_sentiment_emails_test.clj b/test/metabase/task/creator_sentiment_emails_test.clj
new file mode 100644
index 00000000000..feae64aa4d4
--- /dev/null
+++ b/test/metabase/task/creator_sentiment_emails_test.clj
@@ -0,0 +1,93 @@
+(ns ^:mb/once metabase.task.creator-sentiment-emails-test
+  (:require
+   [clojure.test :refer :all]
+   [java-time.api :as t]
+   [metabase.email-test :as et :refer [inbox]]
+   [metabase.public-settings :as public-settings]
+   [metabase.public-settings.premium-features :as premium-features]
+   [metabase.task.creator-sentiment-emails :as creator-sentiment-emails]
+   [metabase.test :as mt]
+   [toucan2.tools.with-temp :as t2.with-temp]))
+
+(deftest send-creator-sentiment-emails!-test
+  (mt/with-fake-inbox
+    (testing "Make sure we only send emails when no surveys is false."
+      (with-redefs [creator-sentiment-emails/no-surveys (constantly true)]
+        (#'creator-sentiment-emails/send-creator-sentiment-emails!)
+        (is (= 0
+               (-> @inbox vals first count)))))
+
+    (testing "Make sure that send-creator-sentiment-emails! only sends emails to creators with the correct month hash."
+      (with-redefs [creator-sentiment-emails/no-surveys (constantly false)
+                    creator-sentiment-emails/fetch-creators (fn [_] [{:email "a@metabase.com"} ;; mods to 1
+                                                                     {:email "b@metabase.com"} ;; mods to 4
+                                                                     {:email "c@metabase.com"}]) ;; mods to 2
+                    t/month (constantly (t/month 2))]
+        (#'creator-sentiment-emails/send-creator-sentiment-emails!)
+        (is (= 1
+               (-> @inbox vals first count)))))
+
+    (mt/reset-inbox!)
+    (testing "Make sure context is included when anon tracking is enabled"
+      (with-redefs [public-settings/anon-tracking-enabled (constantly true)
+                    creator-sentiment-emails/no-surveys (constantly false)
+                    creator-sentiment-emails/fetch-creators (fn [_] [{:email "a@metabase.com"}])
+                    t/month (constantly (t/month 2))]
+        (#'creator-sentiment-emails/send-creator-sentiment-emails!)
+        (is (= 1
+               (count (et/regex-email-bodies #"creator\?context="))))))
+
+    (mt/reset-inbox!)
+    (testing "Make sure context isn't included when anon tracking is enabled"
+      (with-redefs [public-settings/anon-tracking-enabled (constantly false)
+                    creator-sentiment-emails/no-surveys (constantly false)
+                    creator-sentiment-emails/fetch-creators (fn [_] [{:email "a@metabase.com"}])
+                    t/month (constantly (t/month 2))]
+        (#'creator-sentiment-emails/send-creator-sentiment-emails!)
+        (is (= 0
+               (count (et/regex-email-bodies #"creator\?context="))))))
+
+    (mt/reset-inbox!)
+    (testing "Make sure external services message is included when is self hosted"
+      (with-redefs [premium-features/is-hosted? (constantly false)
+                    creator-sentiment-emails/no-surveys (constantly false)
+                    creator-sentiment-emails/fetch-creators (fn [_] [{:email "a@metabase.com"}])
+                    t/month (constantly (t/month 2))]
+        (#'creator-sentiment-emails/send-creator-sentiment-emails!)
+        (is (= 1
+               (count (et/regex-email-bodies #"external services"))))))
+
+    (mt/reset-inbox!)
+    (testing "Make sure external services isn't included when not self hosted"
+      (with-redefs [premium-features/is-hosted? (constantly true)
+                    creator-sentiment-emails/no-surveys (constantly false)
+                    creator-sentiment-emails/fetch-creators (fn [_] [{:email "a@metabase.com"}])
+                    t/month (constantly (t/month 2))]
+        (#'creator-sentiment-emails/send-creator-sentiment-emails!)
+        (is (= 0
+               (count (et/regex-email-bodies #"external services"))))))))
+
+(deftest fetch-creators-test
+  (let [creator-id 33
+        creator-email "creator@metabase.com"]
+    (t2.with-temp/with-temp [:model/User _ {:email creator-email :id creator-id}
+                             :model/User _ {:email "noncreator@metabase.com"}
+                             :model/Dashboard _ {:creator_id creator-id}
+                             :model/Card _ {:creator_id creator-id :query_type "native"}
+                             :model/Card _ {:creator_id creator-id :query_type "native"}
+                             :model/Card _ {:creator_id creator-id}
+                             :model/Card _ {:creator_id creator-id}
+                             :model/Card _ {:creator_id creator-id}
+                             :model/Card _ {:creator_id creator-id}
+                             :model/Card _ {:creator_id creator-id}
+                             :model/Card _ {:creator_id creator-id}
+                             :model/Card _ {:creator_id creator-id}
+                             :model/Card _ {:creator_id creator-id}]
+      (testing "Test we only fetch creators with the correct number of questions and dashboards."
+        (let [creators (#'creator-sentiment-emails/fetch-creators false)]
+          (is (= 1 (count creators)))
+          (is (= creator-email (-> creators first :email)))))
+
+      (testing "Whitelabelling only fetches superusers (doesn't fetch anyone)."
+        (let [creators (#'creator-sentiment-emails/fetch-creators true)]
+          (is (= 0 (count creators))))))))
-- 
GitLab