From 2a09f301995d04d55e5190f6276f085c0f0f2a91 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Wed, 18 Mar 2026 12:31:39 +0100 Subject: [PATCH] :sparkles: Add nitrate endpoint to delete teams keeping your-penpot projects --- backend/src/app/rpc/commands/management.clj | 15 +++ backend/src/app/rpc/management/nitrate.clj | 127 +++++++++++++++++++- 2 files changed, 141 insertions(+), 1 deletion(-) diff --git a/backend/src/app/rpc/commands/management.clj b/backend/src/app/rpc/commands/management.clj index 0908b358d7..c4d580c37d 100644 --- a/backend/src/app/rpc/commands/management.clj +++ b/backend/src/app/rpc/commands/management.clj @@ -339,6 +339,21 @@ ;; --- COMMAND: Move project (defn move-project + "Moves a project from one team to another. + + Performs comprehensive validation including: + - Permission checks on both source and destination teams + - Team compatibility verification between source and destination + - File features compatibility with destination team + + The operation also: + - Updates the project's team assignment + - Cleans up any broken library relations after the move + + Throws: + - :cant-move-to-same-team if trying to move project to its current team + - Permission exceptions if user lacks required permissions + - Team compatibility exceptions if teams are incompatible" [{:keys [::db/conn] :as cfg} {:keys [profile-id team-id project-id] :as params}] (let [project (db/get-by-id conn :project project-id {:columns [:id :team-id]}) pids (->> (db/query conn :project {:team-id (:team-id project)} {:columns [:id]}) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 563653c65d..08fa458dcb 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -8,6 +8,8 @@ "Internal Nitrate HTTP RPC API. Provides authenticated access to organization management and token validation endpoints." (:require + [app.common.data :as d] + [app.common.exceptions :as ex] [app.common.features :as cfeat] [app.common.schema :as sm] [app.common.types.profile :refer [schema:profile, schema:basic-profile]] @@ -18,12 +20,14 @@ [app.msgbus :as mbus] [app.rpc :as-alias rpc] [app.rpc.commands.files :as files] + [app.rpc.commands.management :as management] [app.rpc.commands.profile :as profile] [app.rpc.commands.teams :as teams] [app.rpc.doc :as doc] [app.rpc.quotes :as quotes] [app.util.services :as sv] - [clojure.set :as set])) + [clojure.set :as set] + [cuerdas.core :as str])) ;; ---- API: authenticate @@ -212,3 +216,124 @@ {:teams (mapv #(select-keys % [:id :name]) teams) :num-files files-count}))))) + +;; ---- API: delete-teams-keeping-your-penpot-projects + +(def ^:private sql:get-projects-and-default-teams + "Get projects from specified teams along with their team owner's default team information. + This query: + - Selects projects (id, team_id, name) from teams in the provided list + - Gets the profile_id of each team owner + - Gets the default_team_id where projects should be moved + - Only includes teams where the user is owner + - Only includes projects that contain at least one non-deleted file + - Excludes deleted projects and teams" + "SELECT p.id AS project_id, + p.team_id AS source_team_id, + p.name AS project_name, + tpr.profile_id, + pr.default_team_id + FROM project AS p + JOIN team AS tm ON p.team_id = tm.id + JOIN team_profile_rel AS tpr ON tm.id = tpr.team_id + JOIN profile AS pr ON tpr.profile_id = pr.id + WHERE p.team_id = ANY(?) + AND p.deleted_at IS NULL + AND tm.deleted_at IS NULL + AND tpr.is_owner IS TRUE + AND EXISTS (SELECT 1 FROM file f WHERE f.project_id = p.id AND f.deleted_at IS NULL);") + +(def ^:private sql:delete-teams + "UPDATE team SET deleted_at = ? WHERE id = ANY(?)") + +(def ^:private schema:delete-teams-keeping-your-penpot-projects + [:map + [:org-name ::sm/text] + [:teams [:vector [:map + [:id ::sm/uuid] + [:is-your-penpot ::sm/boolean]]]]]) + +(def ^:private schema:delete-teams-error + [:map + [:error ::sm/keyword] + [:message ::sm/text] + [:cause ::sm/text] + [:project-id {:optional true} ::sm/uuid] + [:project-name {:optional true} ::sm/text] + [:team-id {:optional true} ::sm/uuid] + [:phase {:optional true} [:enum :move-projects :delete-teams]]]) + +(def ^:private schema:delete-teams-result + [:or [:= nil] schema:delete-teams-error]) + +(defn- ^:private clean-org-name + "Clean and sanitize organization name to remove emojis, special characters, + and prevent potential injections. Only allows alphanumeric characters, + spaces, hyphens, underscores, and parentheses." + [org-name] + (when org-name + (-> org-name + str + str/trim + (str/replace #"[^\w\s\-_()]+" "") + (str/replace #"\s+" " ") + str/trim))) + +(sv/defmethod ::delete-teams-keeping-your-penpot-projects + "For a list of teams, move the projects of your-penpot teams to the + default team of each team owner, then delete all provided teams." + {::doc/added "2.15" + ::sm/params schema:delete-teams-keeping-your-penpot-projects + ::sm/result schema:delete-teams-result} + [cfg {:keys [teams org-name ::rpc/request-at]}] + + (let [your-penpot-team-ids (into [] (comp (filter :is-your-penpot) d/xf:map-id) teams) + all-team-ids (into [] d/xf:map-id teams) + cleaned-org-name (clean-org-name org-name) + org-prefix (if (str/empty? cleaned-org-name) + "imported: " + (str cleaned-org-name " imported: "))] + + (when (seq all-team-ids) + + (db/tx-run! cfg + (fn [{:keys [::db/conn] :as cfg}] + + ;; ---- Move projects ---- + (when (seq your-penpot-team-ids) + (let [ids-array (db/create-array conn "uuid" your-penpot-team-ids) + projects (db/exec! conn [sql:get-projects-and-default-teams ids-array])] + + (doseq [{:keys [default-team-id profile-id project-id project-name source-team-id]} projects + :when default-team-id] + + (try + (management/move-project cfg {:profile-id profile-id + :team-id default-team-id + :project-id project-id}) + + (db/update! conn :project + {:is-default false + :name (str org-prefix project-name)} + {:id project-id}) + + (catch Throwable cause + (ex/raise :type :internal + :code :nitrate-project-move-failed + :context {:project-id project-id + :project-name project-name + :team-id source-team-id} + :cause cause)))))) + + ;; ---- Delete teams ---- + (try + (let [team-ids-array (db/create-array conn "uuid" all-team-ids)] + (db/exec-one! conn [sql:delete-teams request-at team-ids-array])) + (catch Throwable cause + (ex/raise :type :internal + :code :nitrate-team-deletion-failed + :context {:team-ids all-team-ids} + :cause cause)))))) + + nil)) +