From 406167352885204e6ad5e47de60af173b6cb0457 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Fri, 24 Apr 2026 11:35:53 +0200 Subject: [PATCH] :sparkles: Add nitrate api endpoints to get and cancel org invitations (#9124) * :sparkles: Add nitrate api endpoints to get and cancel org invitations * :sparkles: MR changes --- backend/src/app/rpc/management/nitrate.clj | 82 +++++++ .../rpc_management_nitrate_test.clj | 203 ++++++++++++++++++ 2 files changed, 285 insertions(+) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 58a06e5c65..6762de6e80 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -16,6 +16,7 @@ [app.config :as cf] [app.db :as db] [app.media :as media] + [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.commands.files :as files] [app.rpc.commands.nitrate :as cnit] @@ -375,6 +376,87 @@ RETURNING id, name;") nil) +;; API: get-org-invitations + +(def ^:private sql:get-org-invitations + "SELECT DISTINCT ON (email_to) + ti.id, + ti.org_id AS organization_id, + ti.email_to AS email, + ti.created_at AS sent_at, + p.fullname AS name, + p.photo_id + FROM team_invitation AS ti +LEFT JOIN profile AS p + ON p.email = ti.email_to + AND p.deleted_at IS NULL + WHERE ti.valid_until >= now() + AND (ti.org_id = ? OR ti.team_id = ANY(?)) + ORDER BY ti.email_to, ti.valid_until DESC, ti.created_at DESC;") + +(def ^:private schema:get-org-invitations-params + [:map + [:organization-id ::sm/uuid]]) + +(def ^:private schema:get-org-invitations-result + [:vector + [:map + [:id ::sm/uuid] + [:organization-id {:optional true} [:maybe ::sm/uuid]] + [:email ::sm/email] + [:sent-at ::sm/inst] + [:name {:optional true} [:maybe ::sm/text]] + [:photo-url {:optional true} ::sm/uri]]]) + +(sv/defmethod ::get-org-invitations + "Get valid invitations for an organization, returning at most one invitation per email." + {::doc/added "2.16" + ::sm/params schema:get-org-invitations-params + ::sm/result schema:get-org-invitations-result} + [cfg {:keys [organization-id]}] + (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) + team-ids (->> (:teams org-summary) + (map :id) + (filter uuid?) + (into []))] + (db/run! cfg (fn [{:keys [::db/conn]}] + (let [ids-array (db/create-array conn "uuid" team-ids)] + (->> (db/exec! conn [sql:get-org-invitations organization-id ids-array]) + (mapv (fn [{:keys [photo-id] :as invitation}] + (cond-> (dissoc invitation :photo-id) + photo-id + (assoc :photo-url (files/resolve-public-uri photo-id))))))))))) + + +;; API: delete-org-invitations + +(def ^:private sql:delete-org-invitations + "DELETE FROM team_invitation AS ti + WHERE ti.email_to = ? + AND (ti.org_id = ? OR ti.team_id = ANY(?));") + +(def ^:private schema:delete-org-invitations-params + [:map + [:organization-id ::sm/uuid] + [:email ::sm/email]]) + +(sv/defmethod ::delete-org-invitations + "Delete all invitations for one email in an organization scope (org + org teams)." + {::doc/added "2.16" + ::sm/params schema:delete-org-invitations-params} + [cfg {:keys [organization-id email]}] + (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) + clean-email (profile/clean-email email) + team-ids (->> (:teams org-summary) + (map :id) + (filter uuid?) + (into []))] + (db/run! cfg (fn [{:keys [::db/conn]}] + (let [ids-array (db/create-array conn "uuid" team-ids)] + (db/exec! conn [sql:delete-org-invitations clean-email organization-id ids-array])))) + nil)) + + ;; API: remove-from-org diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index 0e85b2d312..99cef73858 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -220,6 +220,209 @@ (t/is (= :not-found (th/ex-type (:error ko-out)))) (t/is (= :profile-not-found (th/ex-code (:error ko-out)))))) +(t/deftest get-org-invitations-returns-valid-deduped-by-email + (let [profile (th/create-profile* 1 {:is-active true}) + team-1 (th/create-team* 1 {:profile-id (:id profile)}) + team-2 (th/create-team* 2 {:profile-id (:id profile)}) + org-id (uuid/random) + org-summary {:id org-id + :teams [{:id (:id team-1)} + {:id (:id team-2)}]} + params {::th/type :get-org-invitations + ::rpc/profile-id (:id profile) + :organization-id org-id}] + + ;; Same email appears in org and team invitations; only one should be returned. + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id org-id + :team-id nil + :email-to "dup@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-1) + :org-id nil + :email-to "dup@example.com" + :created-by (:id profile) + :role "admin" + :valid-until (ct/in-future "72h")}) + + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-2) + :org-id nil + :email-to "valid@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "48h")}) + + ;; Expired invitation should be ignored. + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id org-id + :team-id nil + :email-to "expired@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-past "1h")}) + + (let [out (with-redefs [nitrate/call (fn [_cfg method _params] + (case method + :get-org-summary org-summary + nil))] + (management-command-with-nitrate! params)) + result (:result out) + emails (->> result (map :email) set) + dedup (->> result + (filter #(= "dup@example.com" (:email %))) + first)] + (t/is (th/success? out)) + (t/is (= #{"dup@example.com" "valid@example.com"} emails)) + (t/is (= 2 (count result))) + (t/is (some? (:id dedup))) + (t/is (some? (:sent-at dedup))) + (t/is (nil? (:organization-id dedup))) + (t/is (nil? (:team-id dedup))) + (t/is (nil? (:role dedup))) + (t/is (nil? (:valid-until dedup)))))) + +(t/deftest get-org-invitations-includes-org-level-invitations-when-no-teams + (let [profile (th/create-profile* 1 {:is-active true}) + org-id (uuid/random) + org-summary {:id org-id + :teams []} + params {::th/type :get-org-invitations + ::rpc/profile-id (:id profile) + :organization-id org-id}] + + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id org-id + :team-id nil + :email-to "org-only@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + (let [out (with-redefs [nitrate/call (fn [_cfg method _params] + (case method + :get-org-summary org-summary + nil))] + (management-command-with-nitrate! params)) + result (:result out)] + (t/is (th/success? out)) + (t/is (= 1 (count result))) + (t/is (= "org-only@example.com" (-> result first :email))) + (t/is (some? (-> result first :sent-at)))))) + +(t/deftest get-org-invitations-returns-existing-profile-data + (let [profile (th/create-profile* 1 {:is-active true}) + invited (th/create-profile* 2 {:is-active true + :fullname "Invited User"}) + photo-id (uuid/random) + _ (th/db-insert! :storage-object {:id photo-id + :backend "assets-fs"}) + _ (th/db-update! :profile {:photo-id photo-id} {:id (:id invited)}) + org-id (uuid/random) + org-summary {:id org-id + :teams []} + params {::th/type :get-org-invitations + ::rpc/profile-id (:id profile) + :organization-id org-id}] + + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id org-id + :team-id nil + :email-to (:email invited) + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + (let [out (with-redefs [nitrate/call (fn [_cfg method _params] + (case method + :get-org-summary org-summary + nil))] + (management-command-with-nitrate! params)) + invitation (-> out :result first)] + (t/is (th/success? out)) + (t/is (= "Invited User" (:name invitation))) + (t/is (some? (:sent-at invitation))) + (t/is (str/ends-with? (:photo-url invitation) + (str "/assets/by-id/" photo-id)))))) + +(t/deftest delete-org-invitations-removes-org-and-org-team-invitations-for-email + (let [profile (th/create-profile* 1 {:is-active true}) + team-1 (th/create-team* 1 {:profile-id (:id profile)}) + team-2 (th/create-team* 2 {:profile-id (:id profile)}) + outside-team (th/create-team* 3 {:profile-id (:id profile)}) + org-id (uuid/random) + org-summary {:id org-id + :teams [{:id (:id team-1)} + {:id (:id team-2)}]} + target-email "target@example.com" + params {::th/type :delete-org-invitations + ::rpc/profile-id (:id profile) + :organization-id org-id + :email "TARGET@example.com"}] + + ;; Should be deleted: org-level invitation for same org+email. + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id org-id + :team-id nil + :email-to target-email + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + ;; Should be deleted: team-level invitation for teams belonging to org summary. + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-1) + :org-id nil + :email-to target-email + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-past "1h")}) + + ;; Should remain: different email. + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-2) + :org-id nil + :email-to "other@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + ;; Should remain: same email but outside org scope. + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id outside-team) + :org-id nil + :email-to target-email + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + (let [out (with-redefs [nitrate/call (fn [_cfg method _params] + (case method + :get-org-summary org-summary + nil))] + (management-command-with-nitrate! params)) + remaining-target (th/db-query :team-invitation {:email-to target-email}) + remaining-other (th/db-query :team-invitation {:email-to "other@example.com"})] + (t/is (th/success? out)) + (t/is (nil? (:result out))) + (t/is (= 1 (count remaining-target))) + (t/is (= (:id outside-team) (:team-id (first remaining-target)))) + (t/is (= 1 (count remaining-other)))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Tests: remove-from-org ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;