diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 00ccdac7c6..b9f0949394 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -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) diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index 26d690f166..2f88361693 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -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) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 0e7479ac6c..79bfdf02df 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -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 diff --git a/backend/src/app/rpc/notifications.clj b/backend/src/app/rpc/notifications.clj index 10d0d9f134..a439741092 100644 --- a/backend/src/app/rpc/notifications.clj +++ b/backend/src/app/rpc/notifications.clj @@ -30,4 +30,15 @@ :topic profile-id :organization-id organization-id :organization-name organization-name - :notification notification}))) \ No newline at end of file + :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}))) \ No newline at end of file diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 382264bd66..101dfe19a1 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -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 diff --git a/common/src/app/common/types/organization.cljc b/common/src/app/common/types/organization.cljc index b504954cfb..f19833b585 100644 --- a/common/src/app/common/types/organization.cljc +++ b/common/src/app/common/types/organization.cljc @@ -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." diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index c55d757d80..7810d15a95 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -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)) diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 871d46a11a..ef86cef4f3 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -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))) [:*