Add nitrate api notify-user-orgs-deletion

This commit is contained in:
Pablo Alba 2026-04-27 08:43:28 +02:00 committed by Pablo Alba
parent 61ce4b9e0d
commit b8f1b6e0c3
8 changed files with 313 additions and 54 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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})))

View File

@ -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

View File

@ -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."

View File

@ -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))

View File

@ -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)))
[:*