A notification have: - a payload - more than one subscriptions - more than one handlers where each handler has a channel, optionally a template, and more than one recpients.

(ns metabase.models.notification
  (:require
   [malli.core :as mc]
   [medley.core :as m]
   [metabase.channel.models.channel :as models.channel]
   [metabase.models.audit-log :as audit-log]
   [metabase.models.interface :as mi]
   [metabase.models.util.spec-update :as models.u.spec-update]
   [metabase.permissions.core :as perms]
   [metabase.premium-features.core :as premium-features]
   [metabase.util :as u]
   [metabase.util.malli :as mu]
   [metabase.util.malli.registry :as mr]
   [metabase.util.malli.schema :as ms]
   [methodical.core :as methodical]
   [toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(methodical/defmethod t2/table-name :model/Notification             [_model] :notification)
(methodical/defmethod t2/table-name :model/NotificationSubscription [_model] :notification_subscription)
(methodical/defmethod t2/table-name :model/NotificationHandler      [_model] :notification_handler)
(methodical/defmethod t2/table-name :model/NotificationRecipient    [_model] :notification_recipient)
(methodical/defmethod t2/table-name :model/NotificationCard         [_model] :notification_card)
(doseq [model [:model/Notification
               :model/NotificationSubscription
               :model/NotificationHandler
               :model/NotificationRecipient
               :model/NotificationCard]]
  (doto model
    (derive :metabase/model)
    (derive (if (= model :model/NotificationSubscription)
              :hook/created-at-timestamped?
              :hook/timestamped?))))

------------------------------------------------------------------------------------------------;; :model/Notification ;; ------------------------------------------------------------------------------------------------;;

Set of valid notification types.

(def notification-types
  #{:notification/system-event
    :notification/dashboard
    :notification/card
    ;; for testing only
    :notification/testing})
(t2/deftransforms :model/Notification
  {:payload_type (mi/transform-validator mi/transform-keyword (partial mi/assert-enum notification-types))})
(t2/define-after-select :model/Notification
  [notification]
  (dissoc notification :internal_id))
(methodical/defmethod t2/batched-hydrate [:model/Notification :subscriptions]
  "Batch hydration NotificationSubscriptions for a list of Notifications."
  [_model k notifications]
  (mi/instances-with-hydrated-data
   notifications k
   #(group-by :notification_id
              (t2/select :model/NotificationSubscription :notification_id [:in (map :id notifications)]))
   :id
   {:default []}))
(methodical/defmethod t2/batched-hydrate [:default :payload]
  "Batch hydration payloads for a list of Notifications."
  [_model k notifications]
  (let [payload-type->ids        (u/group-by :payload_type :payload_id
                                             conj #{}
                                             notifications)
        payload-type+id->payload (into {}
                                       (for [[payload-type payload-ids] payload-type->ids]
                                         (case payload-type
                                           :notification/card
                                           (let [notification-cards (t2/hydrate
                                                                     (t2/select :model/NotificationCard
                                                                                :id [:in payload-ids])
                                                                     :card)]
                                             (into {} (for [nc notification-cards]
                                                        [[:notification/card (:id nc)] nc])))
                                           {[payload-type nil] nil})))]
    (for [notification notifications]
      (assoc notification k
             (get payload-type+id->payload [(:payload_type notification)
                                            (:payload_id notification)])))))
(methodical/defmethod t2/batched-hydrate [:model/Notification :handlers]
  "Batch hydration NotificationHandlers for a list of Notifications"
  [_model k notifications]
  (mi/instances-with-hydrated-data
   notifications k
   #(group-by :notification_id
              (t2/select :model/NotificationHandler :notification_id [:in (map :id notifications)]))
   :id
   {:default []}))
(mr/def ::Notification
  [:merge
   [:map
    [:payload_type (ms/enum-decode-keyword notification-types)]]
   [:multi {:dispatch (comp keyword :payload_type)}
    [:notification/system-event
     [:map
      [:payload_id {:optional true} nil?]]]
    [:notification/card
     [:map
      ;; optional during creation
      [:payload_id {:optional true} int?]
      [:creator_id {:optional true} int?]]]
    [:notification/testing :any]]])
(defn- validate-notification
  [notification]
  (mu/validate-throw ::Notification notification))
(t2/define-before-insert :model/Notification
  [instance]
  (validate-notification instance)
  instance)
(defn- update-subscription-trigger!
  [& args]
  (apply (requiring-resolve 'metabase.task.notification/update-subscription-trigger!) args))
(defn- delete-trigger-for-subscription!
  [& args]
  (apply (requiring-resolve 'metabase.task.notification/delete-trigger-for-subscription!) args))
(t2/define-before-update :model/Notification
  [instance]
  (validate-notification instance)
  (when-let [unallowed-key (some #{:payload_type :payload_id :creator_id} (keys (t2/changes instance)))]
    (throw (ex-info (format "Update %s is not allowed." (name unallowed-key))
                    {:status-code 400
                     :changes     (t2/changes instance)})))
  (when (contains? (t2/changes instance) :active)
    (let [subscriptions (t2/select :model/NotificationSubscription
                                   :notification_id (:id instance)
                                   :type :notification-subscription/cron)]
      (doseq [subscription subscriptions]
        (if (:active instance)
          (update-subscription-trigger! subscription)
          (delete-trigger-for-subscription! (:id subscription))))))
  instance)
(t2/define-before-delete :model/Notification
  [instance]
  (doseq [subscription-id (t2/select-pks-set :model/NotificationSubscription
                                             :notification_id (:id instance)
                                             :type :notification-subscription/cron)]
    (delete-trigger-for-subscription! subscription-id))
  (when-let [payload-id (:payload_id instance)]
    (t2/delete! (case (:payload_type instance)
                  :notification/card
                  :model/NotificationCard)
                payload-id))
  instance)
(defmethod audit-log/model-details :model/Notification
  [{:keys [subscriptions handlers] :as fully-hydrated-notification} _event-type]
  (merge
   (select-keys fully-hydrated-notification [:id :payload_type :payload_id :creator_id :active])
   {:subscriptions (map #(dissoc % :id :created_at) subscriptions)
    :handlers      (map (fn [handler]
                          (merge (select-keys [:id :channel_type :channel_id :template_id :active]
                                              handler)
                                 {:recipients (map #(select-keys % [:id :type :user_id :permissions_group_id :details]) (:recipients handler))}))
                        handlers)}))

------------------------------------------------------------------------------------------------;; :model/NotificationSubscription ;; ------------------------------------------------------------------------------------------------;;

(def ^:private subscription-types
  #{:notification-subscription/system-event
    :notification-subscription/cron})
(t2/deftransforms :model/NotificationSubscription
  {:type       (mi/transform-validator mi/transform-keyword (partial mi/assert-enum subscription-types))
   :event_name (mi/transform-validator mi/transform-keyword (partial mi/assert-namespaced "event"))})
(mr/def ::NotificationSubscription
  "Schema for :model/NotificationSubscription."
  [:merge [:map
           [:type (ms/enum-decode-keyword subscription-types)]]
   [:multi {:dispatch (comp keyword :type)}
    [:notification-subscription/system-event
     [:map
      [:event_name                     [:or :keyword :string]]
      [:cron_schedule {:optional true} nil?]]]
    [:notification-subscription/cron
     [:map
      [:cron_schedule                  :string]
      [:event_name    {:optional true} nil?]]]]])

Validate a NotificationSubscription.

(defn- validate-subscription
  [subscription]
  (mu/validate-throw ::NotificationSubscription subscription))
(t2/define-before-insert :model/NotificationSubscription
  [instance]
  (validate-subscription instance)
  instance)
(t2/define-after-insert :model/NotificationSubscription
  [instance]
  (update-subscription-trigger! instance)
  instance)
(t2/define-before-update :model/NotificationSubscription
  [instance]
  (validate-subscription instance)
  (update-subscription-trigger! instance)
  instance)
(t2/define-before-delete :model/NotificationSubscription
  [instance]
  (delete-trigger-for-subscription! (:id instance))
  instance)

------------------------------------------------------------------------------------------------;; :model/NotificationHandler ;; ------------------------------------------------------------------------------------------------;;

(t2/deftransforms :model/NotificationHandler
  {:channel_type (mi/transform-validator mi/transform-keyword (partial mi/assert-namespaced "channel"))})
(methodical/defmethod t2/batched-hydrate [:default :channel]
  "Batch hydration Channels for a list of NotificationHandlers"
  [_model k notification-handlers]
  (mi/instances-with-hydrated-data
   notification-handlers k
   #(when-let [channel-ids (seq (keep :channel_id notification-handlers))]
      (t2/select-fn->fn :id identity :model/Channel
                        :id [:in channel-ids]
                        :active true))
   :channel_id
   {:default nil}))
(methodical/defmethod t2/batched-hydrate [:default :template]
  "Batch hydration ChannelTemplates for a list of NotificationHandlers"
  [_model k notification-handlers]
  (mi/instances-with-hydrated-data
   notification-handlers k
   #(when-let [template-ids (seq (keep :template_id notification-handlers))]
      (t2/select-fn->fn :id identity :model/ChannelTemplate
                        :id [:in template-ids]))
   :template_id
   {:default nil}))
(methodical/defmethod t2/batched-hydrate [:model/NotificationHandler :recipients]
  "Batch hydration NotificationRecipients for a list of NotificationHandlers"
  [_model k notification-handlers]
  (mi/instances-with-hydrated-data
   notification-handlers
   k
   #(group-by :notification_handler_id
              (t2/select :model/NotificationRecipient
                         :notification_handler_id [:in (map :id notification-handlers)]))
   :id
   {:default []}))
(methodical/defmethod t2/batched-hydrate [:default :recipients-detail]
  "Batch hydration of details (user, group members) for NotificationRecipients"
  [_model _k recipients]
  (-> (group-by :type recipients)
      (m/update-existing :notification-recipient/user
                         (fn [recipients]
                           (t2/hydrate recipients :user)))
      (m/update-existing :notification-recipient/group
                         (fn [recipients]
                           (t2/hydrate recipients [:permissions_group :members])))
      vals
      flatten))
(defn- cross-check-channel-type-and-template-type
  [notification-handler]
  (when-let [template-id (:template_id notification-handler)]
    (let [channel-type  (keyword (:channel_type notification-handler))
          template-type (t2/select-one-fn :channel_type [:model/ChannelTemplate :channel_type] template-id)]
      (when (not= channel-type template-type)
        (throw (ex-info "Channel type and template type mismatch"
                        {:status        400
                         :channel-type  channel-type
                         :template-type template-type}))))))
(mr/def ::NotificationHandler
  [:map
   ;; optional during insertion
   [:notification_id {:optional true}       ms/PositiveInt]
   [:channel_type    {:decode/json keyword} [:fn #(= "channel" (-> % keyword namespace))]]
   [:channel_id      {:optional true}       [:maybe ms/PositiveInt]]
   [:template_id     {:optional true}       [:maybe ms/PositiveInt]]
   [:active          {:optional true}       [:maybe :boolean]]])
(defn- validate-notification-handler
  [notification-handler]
  (mu/validate-throw ::NotificationHandler notification-handler))
(t2/define-before-insert :model/NotificationHandler
  [instance]
  (cross-check-channel-type-and-template-type instance)
  (validate-notification-handler instance)
  instance)
(t2/define-before-update :model/NotificationHandler
  [instance]
  (validate-notification-handler instance)
  (when (some #{:channel_id :template_id :channel_type} (-> instance t2/changes keys))
    (cross-check-channel-type-and-template-type instance)
    instance))

------------------------------------------------------------------------------------------------;; :model/NotificationRecipient ;; ------------------------------------------------------------------------------------------------;;

(def ^:private notification-recipient-types
  #{:notification-recipient/user
    :notification-recipient/group
    :notification-recipient/raw-value
    :notification-recipient/template})
(t2/deftransforms :model/NotificationRecipient
  {:type    (mi/transform-validator mi/transform-keyword (partial mi/assert-enum notification-recipient-types))
   :details mi/transform-json})
(mr/def ::NotificationRecipient
  "Schema for :model/NotificationRecipient."
  [:merge [:map
           [:type (ms/enum-decode-keyword notification-recipient-types)]
           [:notification_handler_id {:optional true} ms/PositiveInt]]
   [:multi {:dispatch (comp keyword :type)}
    [:notification-recipient/user
     [:map
      [:user_id                               ms/PositiveInt]
      [:permissions_group_id {:optional true} [:fn nil?]]
      [:details              {:optional true} [:fn empty?]]]]
    [:notification-recipient/group
     [:map
      [:permissions_group_id                  ms/PositiveInt]
      [:user_id              {:optional true} [:fn nil?]]
      [:details              {:optional true} [:fn empty?]]]]
    [:notification-recipient/raw-value
     [:map
      [:details                               [:map {:closed true}
                                               [:value :any]]]
      [:user_id              {:optional true} [:fn nil?]]
      [:permissions_group_id {:optional true} [:fn nil?]]]]
    [:notification-recipient/template
     [:map
      [:details                               [:map {:closed true}
                                               [:pattern                      :string]
                                               [:is_optional {:optional true} :boolean]]]
      [:user_id              {:optional true} [:fn nil?]]
      [:permissions_group_id {:optional true} [:fn nil?]]]]]])
(defn- check-valid-recipient
  [recipient]
  (mu/validate-throw ::NotificationRecipient recipient))
(t2/define-before-insert :model/NotificationRecipient
  [instance]
  (check-valid-recipient instance)
  instance)
(t2/define-before-update :model/NotificationRecipient
  [instance]
  (check-valid-recipient instance)
  instance)

------------------------------------------------------------------------------------------------;; :model/NotificationCard ;; ------------------------------------------------------------------------------------------------;;

Set of valid send conditions for NotificationCard.

(def card-subscription-send-conditions
  #{:has_result
    :goal_above
    :goal_below})
(t2/deftransforms :model/NotificationCard
  {:send_condition (mi/transform-validator mi/transform-keyword (partial mi/assert-enum card-subscription-send-conditions))})
(mr/def ::NotificationCard
  "Schema for :model/NotificationCard."
  [:map
   [:card_id                         ms/PositiveInt]
   [:card           {:optional true} [:maybe :map]]
   [:send_condition {:optional true} (ms/enum-decode-keyword card-subscription-send-conditions)]
   [:send_once      {:optional true} :boolean]])
(t2/define-before-insert :model/NotificationCard
  [instance]
  (merge {:send_condition :has_result
          :send_once      false}
         instance))

------------------------------------------------------------------------------------------------;; Permissions ;; ------------------------------------------------------------------------------------------------;;

Check if the current user can read the payload of a notification.

(defn current-user-can-read-payload?
  [notification]
  (case (:payload_type notification)
    :notification/card
    (mi/can-read? :model/Card (-> notification :payload :card_id))
    :notification/system-event
    (mi/superuser?)
    :notification/testing
    true))

Check if the current user is a recipient of a notification.

(defn current-user-is-recipient?
  [notification]
  (->> (:handlers (t2/hydrate notification [:handlers :recipients]))
       (mapcat :recipients)
       (map :user_id)
       distinct
       (some #{(mi/current-user-id)})
       boolean))

Check if the current user is the creator of a notification.

(defn current-user-is-creator?
  [notification]
  (= (:creator_id notification) (mi/current-user-id)))
(defmethod mi/can-read? :model/Notification
  ([notification]
   (or
    (mi/superuser?)
    (current-user-is-creator? notification)
    (current-user-is-recipient? notification)))
  ([_ pk]
   (mi/can-read? (t2/select-one :model/Notification pk))))
(defmethod mi/can-create? :model/Notification
  [_ notification]
  (or (mi/superuser?)
      (and (current-user-can-read-payload? notification)
           ;; if advanced-permissions is enabled, we require users to have subscription permissions
           (or (not (premium-features/has-feature? :advanced-permissions))
               (perms/current-user-has-application-permissions? :subscription)))))
(defmethod mi/can-update? :model/Notification
  [instance _changes]
  (or
   (mi/superuser?)
   (and
    (current-user-is-creator? instance)
    ;; if advanced-permissions is enabled, we require users to have subscription permissions
    ;; and is the owner of the notification and can read the payload
    (or
     (not (premium-features/has-feature? :advanced-permissions))
     (perms/current-user-has-application-permissions? :subscription))
    (current-user-can-read-payload? instance))))

------------------------------------------------------------------------------------------------;; Public APIs ;; ------------------------------------------------------------------------------------------------;;

(mr/def ::FullyHydratedNotification
  "Fully hydrated notification."
  [:merge
   ::Notification
   [:map
    [:creator       {:optional true} [:maybe :map]]
    [:subscriptions {:optional true} [:sequential ::NotificationSubscription]]
    [:handlers      {:optional true} [:sequential [:merge
                                                   ::NotificationHandler
                                                   [:map
                                                    [:template   {:optional true} [:maybe ::models.channel/ChannelTemplate]]
                                                    [:channel    {:optional true} [:maybe ::models.channel/Channel]]
                                                    [:recipients {:optional true} [:sequential ::NotificationRecipient]]]]]]]
   [:multi {:dispatch (comp keyword :payload_type)}
    [:notification/card [:map
                         [:payload ::NotificationCard]]]
    [::mc/default       :any]]])
(mu/defn hydrate-notification :- [:or ::FullyHydratedNotification [:sequential ::FullyHydratedNotification]]
  "Fully hydrate notifictitons."
  [notification-or-notifications]
  (t2/hydrate notification-or-notifications
              :creator
              :payload
              :subscriptions
              [:handlers :channel :template [:recipients :recipients-detail]]))
(mu/defn notifications-for-card :- [:sequential ::FullyHydratedNotification]
  "Find all active card notifications for a given card-id."
  [card-id :- pos-int?]
  (hydrate-notification (t2/select :model/Notification
                                   :active true
                                   :payload_type :notification/card
                                   :payload_id [:in {:select [:id]
                                                     :from   [:notification_card]
                                                     :where  [:= :card_id card-id]}])))

Find all active notifications for a given event.

(defn notifications-for-event
  [event-name]
  (t2/select :model/Notification
             {:select    [:n.*]
              :from      [[:notification :n]]
              :left-join [[:notification_subscription :ns] [:= :n.id :ns.notification_id]]
              :where     [:and
                          [:= :n.active true]
                          [:= :ns.event_name (u/qualified-name event-name)]
                          [:= :ns.type (u/qualified-name :notification-subscription/system-event)]]}))

Create a new notification with subsciptions. Return the created notification.

(defn create-notification!
  [notification subscriptions handlers+recipients]
  (t2/with-transaction [_conn]
    (let [payload-id      (case (:payload_type notification)
                            (:notification/system-event :notification/testing)
                            nil
                            :notification/card
                            (t2/insert-returning-pk! :model/NotificationCard (:payload notification)))
          notification    (-> notification
                              (assoc :payload_id payload-id)
                              (dissoc :payload))
          instance        (t2/insert-returning-instance! :model/Notification notification)
          notification-id (:id instance)]
      (when (seq subscriptions)
        (t2/insert! :model/NotificationSubscription (map #(assoc % :notification_id notification-id) subscriptions)))
      (doseq [handler handlers+recipients]
        (let [recipients (:recipients handler)
              handler    (-> handler
                             (dissoc :recipients)
                             (assoc :notification_id notification-id))
              handler-id (t2/insert-returning-pk! :model/NotificationHandler handler)]
          (t2/insert! :model/NotificationRecipient (map #(assoc % :notification_handler_id handler-id) recipients))))
      instance)))

Spec for updating a notification.

(models.u.spec-update/define-spec notification-update-spec
  {:model        :model/Notification
   :compare-cols [:active]
   :extra-cols   [:payload_type :internal_id :payload_id]
   :nested-specs {:payload       {:model        :model/NotificationCard
                                  :compare-cols [:send_condition :send_once]
                                  :extra-cols   [:card_id]}
                  :subscriptions {:model        :model/NotificationSubscription
                                  :fk-column    :notification_id
                                  :compare-cols [:notification_id :type :event_name :cron_schedule]
                                  :multi-row?   true}
                  :handlers      {:model        :model/NotificationHandler
                                  :fk-column    :notification_id
                                  :compare-cols [:notification_id :channel_type :channel_id :template_id :active]
                                  :multi-row?   true
                                  :nested-specs {:recipients {:model        :model/NotificationRecipient
                                                              :fk-column    :notification_handler_id
                                                              :compare-cols [:notification_handler_id :type :user_id :permissions_group_id :details]
                                                              :multi-row?   true}}}}})

Update an existing notification with new-notification.

(defn update-notification!
  [existing-notification new-notification]
  (models.u.spec-update/do-update! existing-notification new-notification notification-update-spec))

Unsubscribe a user from a notification.

(defn unsubscribe-user!
  [notification-id user-id]
  (t2/delete! :model/NotificationRecipient
              :user_id user-id
              :notification_handler_id [:in {:select [:id]
                                             :from   [:notification_handler]
                                             :where  [:= :notification_id notification-id]}]))