Seed default notifications on startup. No-op if none of the notifications are changed. If a notification is changed, it will be replaced with a new one.

(ns metabase.notification.seed
  (:require
   [metabase.models.notification :as models.notification]
   [metabase.models.permissions-group :as perms-group]
   [metabase.util :as u]
   [metabase.util.json :as json]
   [metabase.util.log :as log]
   [toucan2.core :as t2]))

A mapping of model to the keys that are used to check if a row is the same.

(def ^:private model->compare-keys
  {:model/Notification             [:payload_type :active]
   :model/NotificationSubscription [:type :event_name :cron_schedule]
   :model/NotificationHandler      [:channel_type :active]
   :model/NotificationRecipient    [:type :user_id :permissions_group_id :details]
   :model/Channel                  [:channel_type :details]
   :model/ChannelTemplate          [:channel_type :name :details]})

Select keys from a map, missing keys are included with value = nil.

(defn- select-keys-nil-vals
  [m ks]
  (merge (into {} (zipmap ks (repeat nil)))
         (select-keys m ks)))
(defn- sanitize-model
  [model data]
  (select-keys-nil-vals data (get model->compare-keys model)))
(defn- sanitize-notification
  [{:keys [subscriptions handlers] :as notification}]
  (assoc (sanitize-model :model/Notification notification)
         :subscriptions (set (mapv #(sanitize-model :model/NotificationSubscription %) subscriptions))
         :handlers      (set (mapv (fn [{:keys [channel template recipients] :as handler}]
                                     (assoc (sanitize-model :model/NotificationHandler handler)
                                            :channel    (sanitize-model :model/Channel channel)
                                            :template   (sanitize-model :model/ChannelTemplate template)
                                            :recipients (set (mapv #(sanitize-model :model/NotificationRecipient %) recipients))))
                                   handlers))))

use json to compare to avoid any difference like keywordizing vs string

(def ^:private serialize-notification (comp json/encode sanitize-notification))

List of notifications that are seeded into the database on startup. This list should only be modified or appended, never removed. In order to remove a notification, it should be marked as inactive.

(def ^:private default-notifications
  (delay [;; user invited
          {:internal_id   "system-event/user-invited"
           :active        true
           :payload_type  :notification/system-event
           :subscriptions [{:type       :notification-subscription/system-event
                            :event_name :event/user-invited}]
           :handlers      [{:active       true
                            :channel_type :channel/email
                            :channel_id   nil
                            :template     {:name         "User joined Email template"
                                           :channel_type :channel/email
                                           :details      {:type           "email/handlebars-resource"
                                                          :subject        "{{payload.custom.user_invited_email_subject}}"
                                                          :path           "metabase/email/new_user_invite.hbs"
                                                          :recipient-type "cc"}}
                            :recipients   [{:type    :notification-recipient/template
                                            :details {:pattern "{{payload.event_info.object.email}}"}}]}]}
          ;; alert new confirmation
          {:internal_id   "system-event/alert-new-confirmation"
           :active        true
           :payload_type  :notification/system-event
           :subscriptions [{:type       :notification-subscription/system-event
                            :event_name :event/alert-create}]
           :handlers      [{:active       true
                            :channel_type :channel/email
                            :channel_id   nil
                            :template     {:name         "Alert Created Email template"
                                           :channel_type "channel/email"
                                           :details      {:type "email/handlebars-resource"
                                                          :subject "You set up an alert"
                                                          :path "metabase/email/alert_new_confirmation.hbs"
                                                          :recipient-type "cc"}}
                            :recipients  [{:type    :notification-recipient/template
                                           :details {:pattern "{{payload.event_info.user.email}}"}}]}]}
          ;; slack token invalid
          {:internal_id   "system-event/slack-token-error"
           :active        true
           :payload_type  :notification/system-event
           :subscriptions [{:type       :notification-subscription/system-event
                            :event_name :event/slack-token-invalid}]
           :handlers      [{:active       true
                            :channel_type :channel/email
                            :channel_id   nil
                            :template     {:name         "Slack Token Error Email template"
                                           :channel_type "channel/email"
                                           :details      {:type "email/handlebars-resource"
                                                          :subject "Your Slack connection stopped working"
                                                          :path "metabase/email/slack_token_error.hbs"
                                                          :recipient-type "cc"}}
                            :recipients   [{:type    :notification-recipient/template
                                            :details {:pattern "{{context.admin_email}}" :is_optional true}}
                                           {:type                 :notification-recipient/group
                                            :permissions_group_id (:id (perms-group/admin))}]}]}]))
(defn- cleanup-notification!
  [internal-id existing-row]
  (t2/delete! :model/Notification :internal_id internal-id)
  (when-let [template-ids (->> existing-row :handlers (keep (comp :id :template)) seq)]
    (t2/delete! :model/ChannelTemplate :id [:in template-ids])))
(defn- create-notification!
  [notification]
  (let [handlers (for [handler (:handlers notification)]
                   (if-let [template (:template handler)]
                     (-> handler
                         (dissoc :template)
                         (assoc :template_id (t2/insert-returning-pk!
                                              :model/ChannelTemplate
                                              template)))
                     handler))]
    (models.notification/create-notification!
     (dissoc notification :handlers :subscriptions)
     (:subscriptions notification)
     handlers)))

Replace an existing notification with a new one

(defn- replace-notification!
  [existing-row {:keys [internal_id] :as new-row}]
  (cleanup-notification! internal_id existing-row)
  (create-notification! new-row))
(defn- action
  [existing-row new-row]
  (cond
    (nil? existing-row)
    :create
    (not= (serialize-notification existing-row) (serialize-notification new-row))
    :replace
    :else
    :skip))
(defn- hydrate-notification
  [notification]
  (t2/hydrate notification
              :subscriptions
              [:handlers :channel :template :recipients]))
(defn- sync-notification!
  [{:keys [internal_id] :as row}]
  (let [existing-notification (some-> (t2/select-one :model/Notification :internal_id internal_id)
                                      hydrate-notification)]
    (u/prog1 (action existing-notification row)
      (case <>
        :create
        (do
          (log/debugf "Creating notification %s" internal_id)
          (create-notification! row))
        :replace
        (do
          (log/debugf "Replacing notification %s" internal_id)
          (replace-notification! existing-notification row))
        :skip
        (log/debugf "Skipping notification %s" internal_id)))))

Seed default notifications into the database. If a notification already exists, it'll be replaced if it's changed, otherwise it'll be skipped.

(defn seed-notification!
  []
  (log/info "Seeding default notifications")
  (let [actions (t2/with-transaction []
                  (doall (for [row @default-notifications]
                           (sync-notification! row))))
        summary (frequencies actions)]
    (log/infof "Seeded notifications: %s" summary)
    summary))