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 | (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 | (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]}])) |