diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index f02c36197f..d8a233931d 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -176,7 +176,8 @@ :code :not-valid-teams)))) -(defn leave-org [{:keys [::db/conn] :as cfg} {:keys [profile-id org-id org-name default-team-id teams-to-delete teams-to-leave skip-validation] :as params}] +(defn leave-org + [{:keys [::db/conn] :as cfg} {:keys [profile-id org-id org-name default-team-id teams-to-delete teams-to-leave skip-validation] :as params}] (let [org-prefix (str "[" (d/sanitize-string org-name) "] ") default-team-files-count (-> (db/exec-one! conn [sql:get-team-files-count default-team-id]) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index e173b1a0a6..2a22193d01 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -393,3 +393,33 @@ RETURNING id, name;") (notifications/notify-user-removed-from-org cfg profile-id org-id org-name "dashboard.user-no-longer-belong-org") nil)) +;; API: get-remove-from-org-summary + +(def ^:private schema:get-remove-from-org-summary-result + [:map + [:teams-to-delete ::sm/int] + [:teams-to-transfer ::sm/int] + [:teams-to-exit ::sm/int]]) + +(sv/defmethod ::get-remove-from-org-summary + "Get a summary of the teams that would be deleted, transferred, or exited + if the user were removed from the organization" + {::doc/added "2.16" + ::sm/params [:map + [:profile-id ::sm/uuid] + [:org-id ::sm/uuid] + [:default-team-id ::sm/uuid]] + ::sm/result schema:get-remove-from-org-summary-result + ::db/transaction true} + [cfg {:keys [profile-id org-id default-team-id]}] + (let [{:keys [valid-teams-to-delete-ids + valid-teams-to-transfer + valid-teams-to-exit + valid-default-team]} (cnit/get-valid-teams cfg org-id profile-id default-team-id)] + (when-not valid-default-team + (ex/raise :type :validation + :code :not-valid-teams)) + {:teams-to-delete (count valid-teams-to-delete-ids) + :teams-to-transfer (count valid-teams-to-transfer) + :teams-to-exit (count valid-teams-to-exit)})) + diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 1a938bee22..86a6d2ecb7 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -239,6 +239,9 @@ (fn [_cfg method _params] (case method :get-org-summary org-summary + :get-org-membership {:organization-id (:id org-summary) + :is-member true} + :remove-profile-from-org nil nil))) (t/deftest remove-from-org-happy-path-no-extra-teams @@ -438,3 +441,149 @@ (t/is (not (th/success? out))) (t/is (= :validation (th/ex-type (:error out)))) (t/is (= :nobody-to-reassign-team (th/ex-code (:error out)))))) + +;; Tests: get-remove-from-org-summary +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest get-remove-from-org-summary-no-extra-teams + ;; User only has a default team — nothing to delete/transfer/exit. + (let [org-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + org-team (th/create-team* 1 {:profile-id (:id user)}) + org-id (uuid/random) + org-summary (make-org-summary + :org-id org-id + :org-name "Acme Org" + :owner-id (:id org-owner) + :your-penpot-teams [(:id org-team)] + :org-teams []) + out (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (management-command-with-nitrate! + {::th/type :get-remove-from-org-summary + ::rpc/profile-id (:id org-owner) + :profile-id (:id user) + :org-id org-id + :default-team-id (:id org-team)}))] + (t/is (th/success? out)) + (t/is (= {:teams-to-delete 0 + :teams-to-transfer 0 + :teams-to-exit 0} + (:result out))))) + +(t/deftest get-remove-from-org-summary-with-teams-to-delete + ;; User owns a sole-member extra org team → 1 to delete. + (let [org-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + extra-team (th/create-team* 3 {:profile-id (:id user)}) + org-team (th/create-team* 99 {:profile-id (:id user)}) + org-id (uuid/random) + org-summary (make-org-summary + :org-id org-id + :org-name "Acme Org" + :owner-id (:id org-owner) + :your-penpot-teams [(:id org-team)] + :org-teams [(:id extra-team)]) + out (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (management-command-with-nitrate! + {::th/type :get-remove-from-org-summary + ::rpc/profile-id (:id org-owner) + :profile-id (:id user) + :org-id org-id + :default-team-id (:id org-team)}))] + (t/is (th/success? out)) + (t/is (= {:teams-to-delete 1 + :teams-to-transfer 0 + :teams-to-exit 0} + (:result out))))) + +(t/deftest get-remove-from-org-summary-with-teams-to-transfer + ;; User owns a multi-member extra org team → 1 to transfer. + (let [org-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + candidate (th/create-profile* 3 {:is-active true}) + extra-team (th/create-team* 4 {:profile-id (:id user)}) + _ (th/create-team-role* {:team-id (:id extra-team) + :profile-id (:id candidate) + :role :editor}) + org-team (th/create-team* 99 {:profile-id (:id user)}) + org-id (uuid/random) + org-summary (make-org-summary + :org-id org-id + :org-name "Acme Org" + :owner-id (:id org-owner) + :your-penpot-teams [(:id org-team)] + :org-teams [(:id extra-team)]) + out (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (management-command-with-nitrate! + {::th/type :get-remove-from-org-summary + ::rpc/profile-id (:id org-owner) + :profile-id (:id user) + :org-id org-id + :default-team-id (:id org-team)}))] + (t/is (th/success? out)) + (t/is (= {:teams-to-delete 0 + :teams-to-transfer 1 + :teams-to-exit 0} + (:result out))))) + +(t/deftest get-remove-from-org-summary-with-teams-to-exit + ;; User is a non-owner member of an org team → 1 to exit. + (let [org-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + extra-team (th/create-team* 5 {:profile-id (:id org-owner)}) + _ (th/create-team-role* {:team-id (:id extra-team) + :profile-id (:id user) + :role :editor}) + org-team (th/create-team* 99 {:profile-id (:id user)}) + org-id (uuid/random) + org-summary (make-org-summary + :org-id org-id + :org-name "Acme Org" + :owner-id (:id org-owner) + :your-penpot-teams [(:id org-team)] + :org-teams [(:id extra-team)]) + out (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (management-command-with-nitrate! + {::th/type :get-remove-from-org-summary + ::rpc/profile-id (:id org-owner) + :profile-id (:id user) + :org-id org-id + :default-team-id (:id org-team)}))] + (t/is (th/success? out)) + (t/is (= {:teams-to-delete 0 + :teams-to-transfer 0 + :teams-to-exit 1} + (:result out))))) + +(t/deftest get-remove-from-org-summary-does-not-mutate + ;; Calling the summary endpoint must not modify any teams. + (let [org-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + extra-team (th/create-team* 6 {:profile-id (:id user)}) + org-team (th/create-team* 99 {:profile-id (:id user)}) + org-id (uuid/random) + org-summary (make-org-summary + :org-id org-id + :org-name "Acme Org" + :owner-id (:id org-owner) + :your-penpot-teams [(:id org-team)] + :org-teams [(:id extra-team)]) + _ (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (management-command-with-nitrate! + {::th/type :get-remove-from-org-summary + ::rpc/profile-id (:id org-owner) + :profile-id (:id user) + :org-id org-id + :default-team-id (:id org-team)}))] + ;; Both teams must still exist and be undeleted + (let [t1 (th/db-get :team {:id (:id org-team)})] + (t/is (some? t1)) + (t/is (nil? (:deleted-at t1)))) + (let [t2 (th/db-get :team {:id (:id extra-team)})] + (t/is (some? t2)) + (t/is (nil? (:deleted-at t2)))) + ;; User must still be a member of both teams + (let [rel1 (th/db-get :team-profile-rel {:team-id (:id org-team) :profile-id (:id user)})] + (t/is (some? rel1))) + (let [rel2 (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})] + (t/is (some? rel2)))))