mirror of
https://github.com/penpot/penpot.git
synced 2026-07-22 22:17:58 +00:00
✨ Add nitrate api notify-user-orgs-deletion
This commit is contained in:
parent
61ce4b9e0d
commit
b8f1b6e0c3
@ -241,6 +241,16 @@
|
||||
"/summary")
|
||||
schema:org-summary params)))
|
||||
|
||||
(defn- get-owned-orgs-api
|
||||
[cfg {:keys [profile-id] :as params}]
|
||||
(let [baseuri (cf/get :nitrate-backend-uri)]
|
||||
(request-to-nitrate cfg :get
|
||||
(str baseuri
|
||||
"/api/users/"
|
||||
profile-id
|
||||
"/owned-organizations")
|
||||
[:vector schema:org-summary]
|
||||
params)))
|
||||
|
||||
(defn- set-team-org-api
|
||||
[cfg {:keys [organization-id team-id is-default] :as params}]
|
||||
@ -342,6 +352,7 @@
|
||||
:get-org-membership (partial get-org-membership-api cfg)
|
||||
:get-org-membership-by-team (partial get-org-membership-by-team-api cfg)
|
||||
:get-org-summary (partial get-org-summary-api cfg)
|
||||
:get-owned-orgs (partial get-owned-orgs-api cfg)
|
||||
:add-profile-to-org (partial add-profile-to-org-api cfg)
|
||||
:remove-profile-from-org (partial remove-profile-from-org-api cfg)
|
||||
:remove-profile-from-all-orgs (partial remove-profile-from-all-orgs-api cfg)
|
||||
|
||||
@ -74,7 +74,7 @@
|
||||
AND t.id = ANY(?)
|
||||
AND t.deleted_at IS NULL")
|
||||
|
||||
(def ^:private sql:get-team-files-count
|
||||
(def sql:get-team-files-count
|
||||
"SELECT count(*) AS total
|
||||
FROM file AS f
|
||||
JOIN project AS p ON (p.id = f.project_id)
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.organization :refer [schema:team-with-organization]]
|
||||
[app.common.types.profile :refer [schema:profile, schema:basic-profile]]
|
||||
[app.common.types.team :refer [schema:team]]
|
||||
@ -27,7 +28,8 @@
|
||||
[app.rpc.doc :as doc]
|
||||
[app.rpc.notifications :as notifications]
|
||||
[app.storage :as sto]
|
||||
[app.util.services :as sv]))
|
||||
[app.util.services :as sv]
|
||||
[app.worker :as wrk]))
|
||||
|
||||
|
||||
(defn- profile-to-map [profile]
|
||||
@ -238,30 +240,111 @@
|
||||
WHERE id = ANY(?)
|
||||
RETURNING id, name;")
|
||||
|
||||
(def ^:private sql:get-teams-files-counts
|
||||
"SELECT p.team_id, COUNT(f.*) AS total
|
||||
FROM file AS f
|
||||
JOIN project AS p ON (p.id = f.project_id)
|
||||
JOIN team AS t ON (t.id = p.team_id)
|
||||
WHERE t.id = ANY(?)
|
||||
AND t.deleted_at IS NULL
|
||||
AND p.deleted_at IS NULL
|
||||
AND f.deleted_at IS NULL
|
||||
GROUP BY p.team_id;")
|
||||
|
||||
(def ^:private schema:notify-org-deletion
|
||||
(def ^:private sql:soft-delete-teams
|
||||
"UPDATE team
|
||||
SET deleted_at = ?
|
||||
WHERE id = ANY(?)
|
||||
RETURNING id, deleted_at;")
|
||||
|
||||
|
||||
;; ---- API: notify-organization-deletion
|
||||
|
||||
(def ^:private schema:notify-organization-deletion
|
||||
[:map
|
||||
[:organization-name ::sm/text]
|
||||
[:teams [:vector ::sm/uuid]]])
|
||||
[:organization-id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::notify-org-deletion
|
||||
|
||||
(defn- soft-delete-teams!
|
||||
"Soft-delete the provided team ids and submit a delete task per team."
|
||||
[{:keys [::db/conn] :as cfg} team-ids]
|
||||
(when (seq team-ids)
|
||||
(let [delay (cf/get-deletion-delay)
|
||||
deleted-at (ct/in-future delay)
|
||||
updated (db/exec! conn [sql:soft-delete-teams
|
||||
deleted-at
|
||||
(db/create-array conn "uuid" team-ids)])]
|
||||
(doseq [{:keys [id deleted-at]} updated]
|
||||
(wrk/submit! {::db/conn conn
|
||||
::wrk/task :delete-object
|
||||
::wrk/params {:object :team
|
||||
:deleted-at deleted-at
|
||||
:id id}}))))
|
||||
nil)
|
||||
|
||||
(defn manage-deleted-organization-teams
|
||||
"For a list of teams, rename those with files and delete those without, then notify users."
|
||||
[cfg {:keys [teams organization-name]}]
|
||||
(let [teams (->> teams (filter uuid?) distinct (into []))]
|
||||
(when (seq teams)
|
||||
(let [org-prefix (str "[" (d/sanitize-string organization-name) "] ")]
|
||||
(db/tx-run!
|
||||
cfg
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(let [teams-array (db/create-array conn "uuid" teams)
|
||||
teams-with-files (->> (db/exec! conn [sql:get-teams-files-counts teams-array])
|
||||
(filter (fn [{:keys [total]}] (pos? total)))
|
||||
(map :team-id)
|
||||
(into #{}))
|
||||
teams-to-keep (->> teams (filter teams-with-files) (into []))
|
||||
teams-to-delete (->> teams (remove teams-with-files) (into []))]
|
||||
|
||||
;; Rename teams that have files in one go
|
||||
(when (seq teams-to-keep)
|
||||
(db/exec! conn [sql:prefix-teams-name-and-unset-default
|
||||
org-prefix
|
||||
(db/create-array conn "uuid" teams-to-keep)]))
|
||||
|
||||
;; Soft-delete empty teams in one go
|
||||
(soft-delete-teams! cfg teams-to-delete)
|
||||
|
||||
(notifications/notify-organization-deletion cfg organization-name teams teams-to-delete)
|
||||
nil)))))))
|
||||
|
||||
|
||||
(sv/defmethod ::notify-organization-deletion
|
||||
"For a list of teams, rename them with the name of the deleted org, and notify
|
||||
of the deletion to the connected users"
|
||||
{::doc/added "2.15"
|
||||
::sm/params schema:notify-org-deletion}
|
||||
[cfg {:keys [teams organization-name]}]
|
||||
(when (seq teams)
|
||||
(let [org-prefix (str "[" (d/sanitize-string organization-name) "] ")]
|
||||
(db/tx-run!
|
||||
cfg
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(let [ids-array (db/create-array conn "uuid" teams)
|
||||
;; Rename projects
|
||||
updated-teams (db/exec! conn [sql:prefix-teams-name-and-unset-default org-prefix ids-array])]
|
||||
::sm/params schema:notify-organization-deletion
|
||||
::rpc/auth false}
|
||||
[cfg {:keys [organization-id]}]
|
||||
(let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id})
|
||||
teams (->> (:teams org-summary)
|
||||
(map :id))]
|
||||
(manage-deleted-organization-teams cfg {:teams teams :organization-name (:name org-summary)})
|
||||
nil))
|
||||
|
||||
;; ---- API: notify-user-organizations-deletion
|
||||
|
||||
(def ^:private schema:notify-user-organizations-deletion
|
||||
[:map
|
||||
[:profile-id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::notify-user-organizations-deletion
|
||||
"For a given user, find all owned organizations and rename or delete their teams."
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:notify-user-organizations-deletion}
|
||||
[cfg {:keys [profile-id]}]
|
||||
(let [owned-orgs (nitrate/call cfg :get-owned-orgs {:profile-id profile-id})]
|
||||
(doseq [org owned-orgs]
|
||||
(let [organization-name (:name org)
|
||||
teams (map :id (:teams org))]
|
||||
(manage-deleted-organization-teams cfg {:teams teams :organization-name organization-name}))))
|
||||
nil)
|
||||
|
||||
|
||||
|
||||
;; Notify users
|
||||
(doseq [team updated-teams]
|
||||
(notifications/notify-team-change cfg {:id (:id team) :name (:name team) :organization {:name organization-name}} "dashboard.org-deleted"))))))))
|
||||
|
||||
;; ---- API: get-profile-by-email
|
||||
|
||||
|
||||
@ -30,4 +30,15 @@
|
||||
:topic profile-id
|
||||
:organization-id organization-id
|
||||
:organization-name organization-name
|
||||
:notification notification})))
|
||||
:notification notification})))
|
||||
|
||||
|
||||
(defn notify-organization-deletion
|
||||
[cfg organization-name teams deleted-teams]
|
||||
(let [msgbus (::mbus/msgbus cfg)]
|
||||
(mbus/pub! msgbus
|
||||
:topic uuid/zero
|
||||
:message {:type :organization-deleted
|
||||
:organization-name organization-name
|
||||
:teams teams
|
||||
:deleted-teams deleted-teams})))
|
||||
@ -11,10 +11,10 @@
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as-alias db]
|
||||
[app.email :as email]
|
||||
[app.msgbus :as mbus]
|
||||
[app.nitrate :as nitrate]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.worker :as wrk]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.set :as set]
|
||||
[clojure.test :as t]
|
||||
@ -165,32 +165,158 @@
|
||||
(t/is (= #{(:id team1) (:id team2)}
|
||||
(->> out :result :teams (map :id) set)))))
|
||||
|
||||
(t/deftest notify-org-deletion-prefixes-teams-and-notifies
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
extra-team (th/create-team* 1 {:profile-id (:id profile)})
|
||||
default-team (th/db-get :team {:id (:default-team-id profile)})
|
||||
teams [(:id default-team) (:id extra-team)]
|
||||
organization-name "Acme / Design"
|
||||
expected-start (str "[" (d/sanitize-string organization-name) "] ")
|
||||
calls (atom [])
|
||||
out (with-redefs [mbus/pub! (fn [_cfg & {:keys [topic message]}]
|
||||
(swap! calls conj {:topic topic
|
||||
:message message}))]
|
||||
(management-command-with-nitrate! {::th/type :notify-org-deletion
|
||||
::rpc/profile-id (:id profile)
|
||||
:teams teams
|
||||
:organization-name organization-name}))
|
||||
updated (map #(th/db-get :team {:id %} {::db/remove-deleted false}) teams)]
|
||||
(t/deftest notify-organization-deletion-prefixes-teams-and-publishes-org-deleted-event
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
;; One team will have files -> it will be kept and renamed.
|
||||
team-with-files (th/db-get :team {:id (:default-team-id profile)})
|
||||
project (th/create-project* 1 {:profile-id (:id profile)
|
||||
:team-id (:id team-with-files)})
|
||||
_ (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:id project)})
|
||||
|
||||
;; One team will be empty -> it will be soft-deleted.
|
||||
empty-team (th/create-team* 1 {:profile-id (:id profile)})
|
||||
|
||||
organization-id (uuid/random)
|
||||
organization-name "Acme / Design"
|
||||
expected-start (str "[" (d/sanitize-string organization-name) "] ")
|
||||
org-summary {:id organization-id
|
||||
:name organization-name
|
||||
:teams [{:id (:id team-with-files)}
|
||||
{:id (:id empty-team)}]}
|
||||
calls (atom [])
|
||||
submitted (atom [])
|
||||
out (with-redefs [nitrate/call (fn [_cfg method params]
|
||||
(t/is (= :get-org-summary method))
|
||||
(t/is (= {:organization-id organization-id} params))
|
||||
org-summary)
|
||||
wrk/submit! (fn [task]
|
||||
(swap! submitted conj task)
|
||||
nil)
|
||||
mbus/pub! (fn [_cfg & {:keys [topic message]}]
|
||||
(swap! calls conj {:topic topic
|
||||
:message message}))]
|
||||
(management-command-with-nitrate! {::th/type :notify-organization-deletion
|
||||
::rpc/profile-id (:id profile)
|
||||
:organization-id organization-id}))
|
||||
updated-with-files (th/db-get :team {:id (:id team-with-files)} {::db/remove-deleted false})
|
||||
updated-empty (th/db-get :team {:id (:id empty-team)} {::db/remove-deleted false})]
|
||||
(t/is (th/success? out))
|
||||
(t/is (nil? (:result out)))
|
||||
|
||||
;; Team with files is kept, unset as default, and renamed with org prefix.
|
||||
(t/is (false? (:is-default updated-with-files)))
|
||||
(t/is (str/starts-with? (:name updated-with-files) expected-start))
|
||||
(t/is (nil? (:deleted-at updated-with-files)))
|
||||
|
||||
;; Empty team is soft-deleted and a delete task is submitted.
|
||||
(t/is (some? (:deleted-at updated-empty)))
|
||||
(t/is (= 1 (count @submitted)))
|
||||
|
||||
;; A single organization-deleted event is published.
|
||||
(t/is (= 1 (count @calls)))
|
||||
(let [{:keys [topic message]} (first @calls)]
|
||||
(t/is (= uuid/zero topic))
|
||||
(t/is (= :organization-deleted (:type message)))
|
||||
(t/is (= organization-name (:organization-name message)))
|
||||
(t/is (= #{(:id team-with-files) (:id empty-team)}
|
||||
(set (:teams message))))
|
||||
(t/is (= #{(:id empty-team)}
|
||||
(set (:deleted-teams message)))))))
|
||||
|
||||
(t/deftest notify-user-organizations-deletion-renames-or-deletes-teams-and-publishes-per-org-events
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
;; org-1: one team with files, one empty
|
||||
org-1-team-files (th/db-get :team {:id (:default-team-id profile)})
|
||||
org-1-proj (th/create-project* 1 {:profile-id (:id profile)
|
||||
:team-id (:id org-1-team-files)})
|
||||
_ (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:id org-1-proj)})
|
||||
org-1-team-empty (th/create-team* 1 {:profile-id (:id profile)})
|
||||
|
||||
;; org-2: one team with files, one empty
|
||||
org-2-team-files (th/create-team* 2 {:profile-id (:id profile)})
|
||||
org-2-proj (th/create-project* 2 {:profile-id (:id profile)
|
||||
:team-id (:id org-2-team-files)})
|
||||
_ (th/create-file* 2 {:profile-id (:id profile)
|
||||
:project-id (:id org-2-proj)})
|
||||
org-2-team-empty (th/create-team* 3 {:profile-id (:id profile)})
|
||||
|
||||
org-1-id (uuid/random)
|
||||
org-2-id (uuid/random)
|
||||
org-1-name "Org One / Design"
|
||||
org-2-name "Org Two"
|
||||
org-1-prefix (str "[" (d/sanitize-string org-1-name) "] ")
|
||||
org-2-prefix (str "[" (d/sanitize-string org-2-name) "] ")
|
||||
owned-orgs [{:id org-1-id
|
||||
:name org-1-name
|
||||
:teams [{:id (:id org-1-team-files)}
|
||||
{:id (:id org-1-team-empty)}]}
|
||||
{:id org-2-id
|
||||
:name org-2-name
|
||||
:teams [{:id (:id org-2-team-files)}
|
||||
{:id (:id org-2-team-empty)}]}]
|
||||
calls (atom [])
|
||||
submitted (atom [])
|
||||
out (with-redefs [nitrate/call (fn [_cfg method params]
|
||||
(case method
|
||||
:get-owned-orgs
|
||||
(do
|
||||
(t/is (= {:profile-id (:id profile)} params))
|
||||
owned-orgs)
|
||||
nil))
|
||||
wrk/submit! (fn [task]
|
||||
(swap! submitted conj task)
|
||||
nil)
|
||||
mbus/pub! (fn [_cfg & {:keys [topic message]}]
|
||||
(swap! calls conj {:topic topic
|
||||
:message message}))]
|
||||
(management-command-with-nitrate! {::th/type :notify-user-organizations-deletion
|
||||
::rpc/profile-id (:id profile)
|
||||
:profile-id (:id profile)}))
|
||||
org-1-updated-files (th/db-get :team {:id (:id org-1-team-files)} {::db/remove-deleted false})
|
||||
org-1-updated-empty (th/db-get :team {:id (:id org-1-team-empty)} {::db/remove-deleted false})
|
||||
org-2-updated-files (th/db-get :team {:id (:id org-2-team-files)} {::db/remove-deleted false})
|
||||
org-2-updated-empty (th/db-get :team {:id (:id org-2-team-empty)} {::db/remove-deleted false})
|
||||
msgs (->> @calls (map :message) vec)
|
||||
org-msg (fn [org-name]
|
||||
(first (filter #(= org-name (:organization-name %)) msgs)))]
|
||||
(t/is (th/success? out))
|
||||
(t/is (nil? (:result out)))
|
||||
|
||||
;; org-1: team with files renamed; empty team deleted
|
||||
(t/is (false? (:is-default org-1-updated-files)))
|
||||
(t/is (str/starts-with? (:name org-1-updated-files) org-1-prefix))
|
||||
(t/is (nil? (:deleted-at org-1-updated-files)))
|
||||
(t/is (some? (:deleted-at org-1-updated-empty)))
|
||||
|
||||
;; org-2: team with files renamed; empty team deleted
|
||||
(t/is (false? (:is-default org-2-updated-files)))
|
||||
(t/is (str/starts-with? (:name org-2-updated-files) org-2-prefix))
|
||||
(t/is (nil? (:deleted-at org-2-updated-files)))
|
||||
(t/is (some? (:deleted-at org-2-updated-empty)))
|
||||
|
||||
;; two delete tasks (one per empty team)
|
||||
(t/is (= 2 (count @submitted)))
|
||||
|
||||
;; one organization-deleted event per org
|
||||
(t/is (= 2 (count @calls)))
|
||||
(doseq [team updated]
|
||||
(t/is (false? (:is-default team)))
|
||||
(t/is (str/starts-with? (:name team) expected-start)))
|
||||
(doseq [call @calls]
|
||||
(t/is (= uuid/zero (:topic call)))
|
||||
(t/is (= :team-org-change (-> call :message :type)))
|
||||
(t/is (= organization-name (-> call :message :team :organization :name)))
|
||||
(t/is (= "dashboard.org-deleted" (-> call :message :notification))))))
|
||||
(t/is (every? #(= uuid/zero (:topic %)) @calls))
|
||||
(t/is (= #{:organization-deleted}
|
||||
(set (map (comp :type :message) @calls))))
|
||||
|
||||
(let [m1 (org-msg org-1-name)
|
||||
m2 (org-msg org-2-name)]
|
||||
(t/is (some? m1))
|
||||
(t/is (some? m2))
|
||||
(t/is (= #{(:id org-1-team-files) (:id org-1-team-empty)}
|
||||
(set (:teams m1))))
|
||||
(t/is (= #{(:id org-1-team-empty)}
|
||||
(set (:deleted-teams m1))))
|
||||
(t/is (= #{(:id org-2-team-files) (:id org-2-team-empty)}
|
||||
(set (:teams m2))))
|
||||
(t/is (= #{(:id org-2-team-empty)}
|
||||
(set (:deleted-teams m2)))))))
|
||||
|
||||
(t/deftest get-profile-by-email-success-and-not-found
|
||||
(let [profile (th/create-profile* 1 {:is-active true
|
||||
|
||||
@ -8,18 +8,21 @@
|
||||
(:require
|
||||
[app.common.schema :as sm]))
|
||||
|
||||
(def schema:organization
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name ::sm/text]
|
||||
[:slug ::sm/text]
|
||||
[:owner-id ::sm/uuid]
|
||||
[:avatar-bg-url ::sm/uri]
|
||||
[:logo-id {:optional true} [:maybe ::sm/uuid]]])
|
||||
|
||||
|
||||
(def schema:team-with-organization
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:is-your-penpot :boolean]
|
||||
[:organization
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name ::sm/text]
|
||||
[:slug ::sm/text]
|
||||
[:owner-id ::sm/uuid]
|
||||
[:avatar-bg-url ::sm/uri]
|
||||
[:logo-id {:optional true} [:maybe ::sm/uuid]]]]])
|
||||
[:organization schema:organization]])
|
||||
|
||||
(def organization->team-keys
|
||||
"Mapping from organization field keys to their corresponding :organization-* team keys."
|
||||
|
||||
@ -729,6 +729,30 @@
|
||||
(when (= organization-id (:organization-id team))
|
||||
(dcm/go-to-dashboard-recent {:team-id :default}))))))))
|
||||
|
||||
|
||||
(defn- handle-organization-deleted
|
||||
[{:keys [organization-name teams deleted-teams]}]
|
||||
(ptk/reify ::handle-organization-deleted
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(when (contains? cf/flags :nitrate)
|
||||
(let [team-id (:current-team-id state)
|
||||
teams-set (set teams)
|
||||
notify? (contains? teams-set team-id)
|
||||
fetch? (some (:teams state) teams)
|
||||
go-to-default? (some #{team-id} deleted-teams)]
|
||||
(rx/concat
|
||||
(when go-to-default? ;; If the user is currently on one of the deleted teams
|
||||
(rx/of (dcm/go-to-dashboard-recent {:team-id :default})))
|
||||
|
||||
(when notify? ;; If the user is currently on one of the org teams
|
||||
(rx/of (ntf/show {:content (tr "dashboard.org-deleted" organization-name)
|
||||
:type :toast
|
||||
:level :info
|
||||
:timeout nil})))
|
||||
(when fetch? ;; If the user belonged to the org
|
||||
(rx/of (dtm/fetch-teams)))))))))
|
||||
|
||||
(defn- process-message
|
||||
[{:keys [type] :as msg}]
|
||||
(case type
|
||||
@ -737,6 +761,7 @@
|
||||
:team-membership-change (dcm/team-membership-change msg)
|
||||
:team-org-change (handle-change-team-org msg)
|
||||
:user-org-change (handle-user-org-change msg)
|
||||
:organization-deleted (handle-organization-deleted msg)
|
||||
nil))
|
||||
|
||||
|
||||
|
||||
@ -1065,7 +1065,7 @@
|
||||
(tr "dashboard.your-penpot")
|
||||
(:name team)))))
|
||||
|
||||
(mf/with-effect [team]
|
||||
(mf/with-effect [(:id team) (:members team)]
|
||||
(st/emit! (dtm/fetch-invitations)))
|
||||
|
||||
[:*
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user