;; This Source Code Form is subject to the terms of the Mozilla Public ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; ;; Copyright (c) KALEIDOS INC Sucursal en EspaƱa SL (ns app.rpc.commands.teams (:require [app.common.data :as d] [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.features :as cfeat] [app.common.schema :as sm] [app.common.time :as ct] [app.common.types.organization :as cto] [app.common.types.team :as types.team] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] [app.db.sql :as sql] [app.email :as eml] [app.features.logical-deletion :as ldel] [app.loggers.audit :as audit] [app.main :as-alias main] [app.media :as media] [app.msgbus :as mbus] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.commands.profile :as profile] [app.rpc.doc :as-alias doc] [app.rpc.permissions :as perms] [app.rpc.quotes :as quotes] [app.setup :as-alias setup] [app.storage :as sto] [app.util.services :as sv] [app.worker :as wrk] [clojure.set :as set])) ;; --- Helpers & Specs (def ^:private sql:team-permissions "SELECT tpr.is_owner, tpr.is_admin, tpr.can_edit FROM team_profile_rel AS tpr JOIN team AS t ON (t.id = tpr.team_id) WHERE tpr.profile_id = ? AND tpr.team_id = ? AND t.deleted_at IS NULL") (defn get-permissions [conn profile-id team-id] (let [rows (db/exec! conn [sql:team-permissions profile-id team-id]) is-owner (boolean (some :is-owner rows)) is-admin (boolean (some :is-admin rows)) can-edit (boolean (some :can-edit rows))] (when (seq rows) {:is-owner is-owner :is-admin (or is-owner is-admin) :can-edit (or is-owner is-admin can-edit) :can-read true}))) (defn get-read-permissions [cfg profile-id team-id] (or (get-permissions cfg profile-id team-id) (perms/get-organization-owner-permissions cfg profile-id :team-id team-id))) (def has-admin-permissions? (perms/make-admin-predicate-fn get-permissions)) (def has-edit-permissions? (perms/make-edition-predicate-fn get-permissions)) (def has-read-permissions? (perms/make-read-predicate-fn get-read-permissions)) (def check-admin-permissions! (perms/make-check-fn has-admin-permissions?)) (def check-edition-permissions! (perms/make-check-fn has-edit-permissions?)) (def check-read-permissions! (perms/make-check-fn has-read-permissions?)) (defn decode-row [{:keys [features subscription] :as row}] (when row (cond-> row (some? features) (assoc :features (db/decode-pgarray features #{})) (some? subscription) (assoc :subscription (db/decode-transit-pgobject subscription))))) ;; FIXME: move (defn check-profile-muted "Check if the member's email is part of the global bounce report" [conn member] (let [email (profile/clean-email (:email member))] (when (and member (not (eml/allow-send-emails? conn member))) (ex/raise :type :validation :code :member-is-muted :email email :hint "the profile has reported repeatedly as spam or has bounces")))) (defn check-email-bounce "Check if the email is part of the global complain report" [conn email show?] (when (eml/has-bounce-reports? conn email) (ex/raise :type :restriction :code :email-has-permanent-bounces :email (if show? email "private") :hint "this email has been repeatedly reported as bounce"))) (defn check-email-spam "Check if the member email is part of the global complain report" [conn email show?] (when (eml/has-complaint-reports? conn email) (ex/raise :type :restriction :code :email-has-complaints :email (if show? email "private") :hint "this email has been repeatedly reported as spam"))) ;; --- Query: Teams (def sql:get-teams-with-permissions "SELECT t.*, tp.is_owner, tp.is_admin, tp.can_edit, (t.id = ?) AS is_default FROM team_profile_rel AS tp JOIN team AS t ON (t.id = tp.team_id) WHERE t.deleted_at IS null AND tp.profile_id = ? ORDER BY tp.created_at ASC") (def sql:get-teams-with-permissions-and-subscription "SELECT t.*, tp.is_owner, tp.is_admin, tp.can_edit, (t.id = ?) AS is_default, jsonb_build_object( '~:type', COALESCE(p.props->'~:subscription'->>'~:type', 'professional'), '~:status', CASE COALESCE(p.props->'~:subscription'->>'~:type', 'professional') WHEN 'professional' THEN 'active' ELSE COALESCE(p.props->'~:subscription'->>'~:status', 'incomplete') END, '~:seats', p.props->'~:subscription'->'~:quantity' ) AS subscription FROM team_profile_rel AS tp JOIN team AS t ON (t.id = tp.team_id) JOIN team_profile_rel AS tpr ON (tpr.team_id = t.id AND tpr.is_owner IS true) JOIN profile AS p ON (tpr.profile_id = p.id) WHERE t.deleted_at IS null AND tp.profile_id = ? ORDER BY tp.created_at ASC") (defn process-permissions [team] (let [is-owner (:is-owner team) is-admin (:is-admin team) can-edit (:can-edit team) permissions {:type :membership :is-owner is-owner :is-admin (or is-owner is-admin) :can-edit (or is-owner is-admin can-edit)}] (-> team (dissoc :is-owner :is-admin :can-edit) (assoc :permissions permissions)))) (def ^:private xform:process-teams (comp (map decode-row) (map process-permissions))) (defn get-teams [conn profile-id] (let [profile (profile/get-profile conn profile-id) sql (if (contains? cf/flags :subscriptions) sql:get-teams-with-permissions-and-subscription sql:get-teams-with-permissions)] (->> (db/exec! conn [sql (:default-team-id profile) profile-id]) (into [] xform:process-teams)))) (def ^:private schema:get-teams [:map {:title "get-teams"}]) (sv/defmethod ::get-teams {::doc/added "1.17" ::sm/params schema:get-teams} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}] (dm/with-open [conn (db/open pool)] (cond->> (get-teams conn profile-id) (contains? cf/flags :admin-console) (map #(nitrate/add-organization-info-to-team cfg % params)) (contains? cf/flags :admin-console) (remove #(get-in % [:organization :expired-license]))))) (def ^:private sql:get-owned-teams "SELECT t.id, t.name, (SELECT count(*) FROM team_profile_rel WHERE team_id=t.id) AS total_members, (SELECT count(*) FROM team_profile_rel WHERE team_id=t.id AND can_edit=true) AS total_editors FROM team AS t JOIN team_profile_rel AS tpr ON (tpr.team_id = t.id) WHERE t.is_default IS false AND tpr.is_owner IS true AND tpr.profile_id = ? AND t.deleted_at IS NULL") (defn- get-owned-teams [cfg profile-id] (->> (db/exec! cfg [sql:get-owned-teams profile-id]) (into [] (map decode-row)))) (sv/defmethod ::get-owned-teams {::doc/added "2.8.0" ::sm/params schema:get-teams} [cfg {:keys [::rpc/profile-id]}] (get-owned-teams cfg profile-id)) ;; --- Query: Team (by ID) (declare get-team) (def ^:private schema:get-team [:and [:map {:title "get-team"} [:id {:optional true} ::sm/uuid] [:file-id {:optional true} ::sm/uuid]] [:fn (fn [params] (or (contains? params :id) (contains? params :file-id)))]]) (sv/defmethod ::get-team {::doc/added "1.17" ::rpc/id-type :team ::sm/params schema:get-team} [cfg {:keys [::rpc/profile-id id file-id] :as params}] (let [team (get-team cfg :profile-id profile-id :team-id id :file-id file-id)] (if (contains? cf/flags :admin-console) (nitrate/add-organization-info-to-team cfg team params) team))) (defn- get-organization-owner-viewer-team "When `profile-id` is a non-member owner of the organization that owns the requested team, returns the team shaped with viewer permissions; otherwise nil. `cfg` must carry the nitrate client." [cfg profile-id default-team-id params] (when-let [team-id (perms/resolve-team-id cfg params)] (when (nitrate/organization-owner-of-team? cfg profile-id team-id) (when-let [team (db/get* cfg :team {:id team-id})] (when-not (db/is-row-deleted? team) (-> team (decode-row) (merge perms/viewer-role-flags) (assoc :is-default (= team-id default-team-id)) (process-permissions))))))) (defn get-team [cfg & {:keys [profile-id team-id project-id file-id] :as params}] (assert (uuid? profile-id) "profile-id is mandatory") (let [{:keys [default-team-id] :as profile} (profile/get-profile cfg profile-id) sql (if (contains? cf/flags :subscriptions) sql:get-teams-with-permissions-and-subscription sql:get-teams-with-permissions) result (cond (some? team-id) (let [sql (str "WITH teams AS (" sql ") " "SELECT * FROM teams WHERE id=?")] (db/exec-one! cfg [sql default-team-id profile-id team-id])) (some? project-id) (let [sql (str "WITH teams AS (" sql ") " "SELECT t.* FROM teams AS t " " JOIN project AS p ON (p.team_id = t.id) " " WHERE p.id=?")] (db/exec-one! cfg [sql default-team-id profile-id project-id])) (some? file-id) (let [sql (str "WITH teams AS (" sql ") " "SELECT t.* FROM teams AS t " " JOIN project AS p ON (p.team_id = t.id) " " JOIN file AS f ON (f.project_id = p.id) " " WHERE f.id=?")] (db/exec-one! cfg [sql default-team-id profile-id file-id])) :else (throw (IllegalArgumentException. "invalid arguments")))] (if result (-> result (decode-row) (process-permissions)) (or (get-organization-owner-viewer-team cfg profile-id default-team-id params) (ex/raise :type :not-found :code :team-does-not-exist))))) ;; --- Query: Team Members (def sql:team-members "SELECT tp.*, p.id, p.email, p.fullname AS name, p.fullname AS fullname, p.photo_id, p.is_active FROM team_profile_rel AS tp JOIN profile AS p ON (p.id = tp.profile_id) WHERE tp.team_id = ?") (defn get-team-members [conn team-id] (db/exec! conn [sql:team-members team-id])) (def ^:private schema:get-team-memebrs [:map {:title "get-team-members"} [:team-id ::sm/uuid]]) (sv/defmethod ::get-team-members {::doc/added "1.17" ::sm/params schema:get-team-memebrs} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] (check-read-permissions! cfg profile-id team-id) (get-team-members conn team-id))) ;; --- Query: Team Users (declare get-users) (declare get-team-for-file) (def ^:private schema:get-team-users [:and {:title "get-team-users"} [:map [:team-id {:optional true} ::sm/uuid] [:file-id {:optional true} ::sm/uuid]] [:fn #(or (contains? % :team-id) (contains? % :file-id))]]) ;; FIXME: split in two separated requests (sv/defmethod ::get-team-users "Get team users by team-id or by file-id" {::doc/added "1.17" ::sm/params schema:get-team-users} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id file-id]}] (dm/with-open [conn (db/open pool)] (if team-id (do (check-read-permissions! cfg profile-id team-id) (get-users conn team-id)) (let [{team-id :id} (get-team-for-file conn file-id)] (check-read-permissions! cfg profile-id team-id) (get-users conn team-id))))) ;; This is a similar query to team members but can contain more data ;; because some user can be explicitly added to project or file (not ;; implemented in UI) (def sql:team-users "select pf.id, pf.fullname, pf.photo_id, pf.email from profile as pf inner join team_profile_rel as tpr on (tpr.profile_id = pf.id) where tpr.team_id = ? union select pf.id, pf.fullname, pf.photo_id, pf.email from profile as pf inner join project_profile_rel as ppr on (ppr.profile_id = pf.id) inner join project as p on (ppr.project_id = p.id) where p.team_id = ? union select pf.id, pf.fullname, pf.photo_id, pf.email from profile as pf inner join file_profile_rel as fpr on (fpr.profile_id = pf.id) inner join file as f on (fpr.file_id = f.id) inner join project as p on (f.project_id = p.id) where p.team_id = ?") (defn get-users [conn team-id] (db/exec! conn [sql:team-users team-id team-id team-id])) ;; Get the users but add the props property (def sql:team-users+props "select pf.id, pf.fullname, pf.photo_id, pf.email, pf.props from profile as pf inner join team_profile_rel as tpr on (tpr.profile_id = pf.id) where tpr.team_id = ? union select pf.id, pf.fullname, pf.photo_id, pf.email, pf.props from profile as pf inner join project_profile_rel as ppr on (ppr.profile_id = pf.id) inner join project as p on (ppr.project_id = p.id) where p.team_id = ? union select pf.id, pf.fullname, pf.photo_id, pf.email, pf.props from profile as pf inner join file_profile_rel as fpr on (fpr.profile_id = pf.id) inner join file as f on (fpr.file_id = f.id) inner join project as p on (f.project_id = p.id) where p.team_id = ?") (defn get-users+props [conn team-id] (db/exec! conn [sql:team-users+props team-id team-id team-id])) (def sql:get-team-by-file "SELECT t.* FROM team AS t JOIN project AS p ON (p.team_id = t.id) JOIN file AS f ON (f.project_id = p.id) WHERE f.id = ?") (defn get-team-for-file [conn file-id] (let [team (->> (db/exec! conn [sql:get-team-by-file file-id]) (remove db/is-row-deleted?) (map decode-row) (first))] (when-not team (ex/raise :type :not-found :code :object-not-found :hint "database object not found")) team)) ;; --- Query: Team Stats (declare get-team-stats) (def ^:private schema:get-team-stats [:map {:title "get-team-stats"} [:team-id ::sm/uuid]]) (sv/defmethod ::get-team-stats {::doc/added "1.17" ::sm/params schema:get-team-stats} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] (check-read-permissions! cfg profile-id team-id) (get-team-stats conn team-id))) (def sql:team-stats "select (select count(*) from project where team_id = ?) as projects, (select count(*) from file as f join project as p on (p.id = f.project_id) where p.team_id = ?) as files") (defn get-team-stats [conn team-id] (db/exec-one! conn [sql:team-stats team-id team-id])) ;; --- Query: Team invitations (def ^:private schema:get-team-invitations [:map {:title "get-team-invitations"} [:team-id ::sm/uuid]]) (def sql:team-invitations "SELECT email_to AS email, role, (valid_until < ?::timestamptz) AS expired FROM team_invitation WHERE team_id = ? ORDER BY valid_until DESC, created_at DESC") (defn get-team-invitations [conn team-id] (let [now (ct/now)] (->> (db/exec! conn [sql:team-invitations now team-id]) (mapv #(update % :role keyword))))) (sv/defmethod ::get-team-invitations {::doc/added "1.17" ::sm/params schema:get-team-invitations} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}] (dm/with-open [conn (db/open pool)] (check-read-permissions! cfg profile-id team-id) (get-team-invitations conn team-id))) ;; --- COMMAND QUERY: get-team-info (defn get-team-info [cfg {:keys [id] :as params}] (-> (db/get* cfg :team {:id id} {::sql/columns [:id :is-default :features]}) (decode-row))) (sv/defmethod ::get-team-info "Retrieve minimal team info by its ID." {::rpc/auth false ::doc/added "2.2.0" ::sm/params schema:get-team} [cfg params] (db/tx-run! cfg get-team-info params)) ;; --- Mutation: Create Team (declare create-team) (declare create-project) (declare create-project-role) (declare ^:private create-team*) (declare ^:private create-team-role) (declare ^:private create-team-default-project) (def ^:private schema:create-team [:map {:title "create-team"} [:name types.team/schema:team-name] [:features {:optional true} ::cfeat/features] [:id {:optional true} ::sm/uuid] [:organization-id {:optional true} ::sm/uuid] [:is-default {:optional true} :boolean]]) (sv/defmethod ::create-team {::doc/added "1.17" ::sm/params schema:create-team} [cfg {:keys [::rpc/profile-id organization-id] :as params}] (quotes/check! cfg {::quotes/id ::quotes/teams-per-profile ::quotes/profile-id profile-id}) ;; When creating inside an organization, verify the user has permission to do so. ;; Fail closed: if organization permissions cannot be fetched, deny the operation. (when (and organization-id (contains? cf/flags :admin-console)) (let [organization-perms (nitrate/call cfg :get-organization-permissions {:organization-id organization-id})] (if (nil? organization-perms) (ex/raise :type :validation :code :not-allowed :hint "Unable to verify organization permissions") (when-not (cto/allowed? :create-team {:organization-perms organization-perms :profile-id profile-id}) (ex/raise :type :validation :code :not-allowed :hint "You are not allowed to create teams in this organization"))))) (let [features (-> (cfeat/get-enabled-features cf/flags) (set/difference cfeat/frontend-only-features) (set/difference cfeat/no-team-inheritable-features)) params (-> params (assoc :profile-id profile-id) (assoc :features features)) team (db/tx-run! cfg create-team params)] (with-meta team {::audit/props {:id (:id team)}}))) (defn create-default-organization-team [cfg profile-id organization-id] (quotes/check! cfg {::quotes/id ::quotes/teams-per-profile ::quotes/profile-id profile-id}) (let [features (-> (cfeat/get-enabled-features cf/flags) (set/difference cfeat/frontend-only-features) (set/difference cfeat/no-team-inheritable-features)) params {:profile-id profile-id :name "Your Penpot" :features features :organization-id organization-id :is-default true} team (create-team cfg params)] (select-keys team [:id]))) (defn initialize-user-in-organization "If needed, create a default team for the user on the organization, and initialize the user in the organization." ([cfg profile-id organization-id] (initialize-user-in-organization cfg profile-id organization-id nil)) ([cfg profile-id organization-id email] (assert (db/connection-map? cfg) "expected cfg with valid connection") (when (contains? cf/flags :admin-console) (db/tx-run! cfg (fn [{:keys [::db/conn] :as tx-cfg}] (let [membership (nitrate/call cfg :get-organization-membership {:profile-id profile-id :organization-id organization-id})] ;; Only when the user doesn't belong to the organization yet (when (and (some? (:organization-id membership)) ;; the organization exists (not (:is-member membership))) ;; the user is not a member of the organization yet (let [organization-id organization-id default-team (create-default-organization-team (assoc tx-cfg ::db/conn conn) profile-id organization-id) default-team-id (:id default-team) result (nitrate/call tx-cfg :add-profile-to-organization (cond-> {:profile-id profile-id :team-id default-team-id :organization-id organization-id} (some? email) (assoc :email email)))] (when (not (:is-member result)) (ex/raise :type :internal :code :failed-add-profile-organization-nitrate :context {:profile-id profile-id :organization-id organization-id :default-team-id default-team-id})) default-team-id)))))))) (defn add-profile-to-team! ([cfg params] (add-profile-to-team! cfg params nil)) ([{:keys [::db/conn] :as cfg} {:keys [:profile-id :team-id] :as params} options] (assert (db/connection-map? cfg) "expected cfg with valid connection") (when (contains? cf/flags :admin-console) (let [membership (nitrate/call cfg :get-organization-membership-by-team {:profile-id profile-id :team-id team-id})] ;; Only when the team belong to an organization and the user is not a member (when (and (some? (:organization-id membership)) ;; the team do belong to an organization (not (:is-member membership))) ;; the user is not a member of the organization yet (initialize-user-in-organization cfg profile-id (:organization-id membership))))) (db/insert! conn :team-profile-rel (assoc params :id (uuid/next)) options))) (defn create-team "This is a complete team creation process, it creates the team object and all related objects (default role and default project)." [{:keys [::db/conn] :as cfg} params] (assert (db/connection-map? cfg) "expected cfg with valid connection") (let [team (create-team* conn params) params (assoc params :team-id (:id team) :role :owner) project (create-team-default-project conn params)] (create-team-role cfg params) ;; Set team organization in Nitrate if organization-id is provided (when (and (contains? cf/flags :admin-console) (:organization-id params)) (nitrate/set-team-organization cfg team params)) (assoc team :default-project-id (:id project)))) (defn- create-team* [conn {:keys [id name is-default features] :as params}] (let [id (or id (uuid/next)) is-default (if (boolean? is-default) is-default false) features (db/create-array conn "text" features) team (db/insert! conn :team {:id id :name name :features features :is-default is-default})] (decode-row team))) (defn- create-team-role [cfg {:keys [profile-id team-id role] :as params}] (assert (db/connection-map? cfg) "expected cfg with valid connection") (let [params {:team-id team-id :profile-id profile-id}] (->> (perms/assign-role-flags params role) (add-profile-to-team! cfg)))) (defn- create-team-default-project [conn {:keys [profile-id team-id] :as params}] (let [project {:id (uuid/next) :team-id team-id :name "Drafts" :is-default true} project (create-project conn project)] (create-project-role conn profile-id (:id project) :owner) project)) ;; NOTE: we have project creation here because there are cyclic ;; dependency between teams and projects namespaces, and the project ;; creation happens in both sides, on team creation and on simple ;; project creation, so it make sense to have this functions in this ;; namespace too. (defn create-project [conn {:keys [id team-id name is-default created-at modified-at]}] (let [id (or id (uuid/next)) is-default (if (boolean? is-default) is-default false) params {:id id :name name :team-id team-id :is-default is-default :created-at created-at :modified-at modified-at}] (db/insert! conn :project (d/without-nils params)))) (defn create-project-role [conn profile-id project-id role] (let [params {:project-id project-id :profile-id profile-id :id (uuid/next)}] (->> (perms/assign-role-flags params role) (db/insert! conn :project-profile-rel)))) ;; --- Mutation: Update Team (def ^:private schema:update-team [:map {:title "update-team"} [:name types.team/schema:team-name] [:id ::sm/uuid]]) (sv/defmethod ::update-team {::doc/added "1.17" ::rpc/id-type :team ::sm/params schema:update-team ::db/transaction true} [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id name]}] (check-edition-permissions! conn profile-id id) (db/update! conn :team {:name name} {:id id}) nil) ;; --- Mutation: Leave Team (defn leave-team [{:keys [::db/conn ::mbus/msgbus]} {:keys [profile-id id reassign-to]}] (let [perms (get-permissions conn profile-id id) members (get-team-members conn id)] (cond ;; we can only proceed if there are more members in the team ;; besides the current profile (<= (count members) 1) (ex/raise :type :validation :code :no-enough-members-for-leave :context {:members (count members)}) ;; if the `reassign-to` is filled and has a different value ;; than the current profile-id, we proceed to reassing the ;; owner role to profile identified by the `reassign-to`. ;; Ignore the reasignation if the current profile is not ;; the owner (and reassign-to (not= reassign-to profile-id) (:is-owner perms)) (let [member (d/seek #(= reassign-to (:id %)) members)] (when-not member (ex/raise :type :not-found :code :member-does-not-exist)) ;; unasign owner role to current profile (db/update! conn :team-profile-rel {:is-owner false} {:team-id id :profile-id profile-id}) ;; assign owner role to new profile (db/update! conn :team-profile-rel (get types.team/permissions-for-role :owner) {:team-id id :profile-id reassign-to}) ;; notify new owner (mbus/pub! msgbus :topic reassign-to :message {:type :team-role-change :topic reassign-to :team-id id :role :owner})) ;; and finally, if all other conditions does not match and the ;; current profile is owner, we dont allow it because there ;; must always be an owner. (:is-owner perms) (ex/raise :type :validation :code :owner-cant-leave-team :hint "releasing owner before leave")) (db/delete! conn :team-profile-rel {:profile-id profile-id :team-id id}) nil)) (def ^:private schema:leave-team [:map {:title "leave-team"} [:id ::sm/uuid] [:reassign-to {:optional true} ::sm/uuid]]) (sv/defmethod ::leave-team {::doc/added "1.17" ::rpc/id-type :team ::sm/params schema:leave-team ::db/transaction true} [cfg {:keys [::rpc/profile-id] :as params}] (leave-team cfg (assoc params :profile-id profile-id))) ;; --- Mutation: Delete Team (defn delete-team "Mark a team for deletion" [{:keys [::db/conn] :as cfg} {:keys [profile-id team-id] :as params}] (let [team (get-team conn :profile-id profile-id :team-id team-id) team (if (contains? cf/flags :admin-console) (nitrate/add-organization-info-to-team cfg team params) team) perms (get team :permissions) organization (:organization team) in-organization? (and (contains? cf/flags :admin-console) organization) can-delete? (if in-organization? (cto/allowed? :delete-team {:organization-perms {:owner-id (dm/get-in team [:organization :owner-id]) :permissions (dm/get-in team [:organization :permissions])} :profile-id profile-id :team-perms perms}) (boolean (:is-owner perms)))] (when-not can-delete? (ex/raise :type :validation :code :only-owner-can-delete-team)) ;; Protect the user's personal default team from deletion. ;; Organization-scoped default teams ("Your Penpot") are allowed to be deleted when they have no files. (when (and (:is-default team) (not in-organization?)) (ex/raise :type :validation :code :non-deletable-team :hint "impossible to delete default team")) (let [delay (ldel/get-deletion-delay team) team (db/update! conn :team {:deleted-at (ct/in-future delay)} {:id team-id} {::db/return-keys true})] ;; Api call to nitrate (when (contains? cf/flags :admin-console) (nitrate/call cfg :delete-team {:profile-id profile-id :team-id team-id})) (wrk/submit! {::db/conn conn ::wrk/task :delete-object ::wrk/params {:object :team :deleted-at (:deleted-at team) :id team-id}}) team))) (def ^:private schema:delete-team [:map {:title "delete-team"} [:id ::sm/uuid]]) (sv/defmethod ::delete-team {::doc/added "1.17" ::rpc/id-type :team ::sm/params schema:delete-team ::db/transaction true} [cfg {:keys [::rpc/profile-id id] :as params}] (delete-team cfg {:team-id id :profile-id profile-id}) nil) ;; --- Mutation: Team Update Role (defn update-team-member-role [{:keys [::db/conn ::mbus/msgbus]} {:keys [profile-id team-id member-id role] :as params}] ;; We retrieve all team members instead of query the ;; database for a single member. This is just for ;; convenience, if this becomes a bottleneck or problematic, ;; we will change it to more efficient fetch mechanisms. (let [perms (get-permissions conn profile-id team-id) members (get-team-members conn team-id) member (d/seek #(= member-id (:id %)) members) is-owner? (:is-owner perms) is-admin? (:is-admin perms)] ;; If no member is found, just 404 (when-not member (ex/raise :type :not-found :code :member-does-not-exist)) ;; First check if we have permissions to change roles (when-not (or is-owner? is-admin?) (ex/raise :type :validation :code :insufficient-permissions)) ;; Don't allow change role of owner member (when (:is-owner member) (ex/raise :type :validation :code :cant-change-role-to-owner)) ;; Don't allow promote to owner to admin users. (when (and (not is-owner?) (= role :owner)) (ex/raise :type :validation :code :cant-promote-to-owner)) (mbus/pub! msgbus :topic member-id :message {:type :team-role-change :topic member-id :team-id team-id :role role}) (let [params (get types.team/permissions-for-role role)] ;; Only allow single owner on team (when (= role :owner) (db/update! conn :team-profile-rel {:is-owner false} {:team-id team-id :profile-id profile-id})) (db/update! conn :team-profile-rel params {:team-id team-id :profile-id member-id}) nil))) (def ^:private schema:update-team-member-role [:map {:title "update-team-member-role"} [:team-id ::sm/uuid] [:member-id ::sm/uuid] [:role types.team/schema:role]]) (sv/defmethod ::update-team-member-role {::doc/added "1.17" ::sm/params schema:update-team-member-role} [cfg {:keys [::rpc/profile-id] :as params}] (db/tx-run! cfg update-team-member-role (assoc params :profile-id profile-id))) ;; --- Mutation: Delete Team Member (def ^:private schema:delete-team-member [:map {:title "delete-team-member"} [:team-id ::sm/uuid] [:member-id ::sm/uuid]]) (sv/defmethod ::delete-team-member {::doc/added "1.17" ::sm/params schema:delete-team-member ::db/transaction true} [{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id team-id member-id] :as params}] (let [team (get-team conn :profile-id profile-id :team-id team-id) perms (get-permissions conn profile-id team-id)] (when-not (or (:is-owner perms) (:is-admin perms)) (ex/raise :type :validation :code :insufficient-permissions)) (when (= member-id profile-id) (ex/raise :type :validation :code :cant-remove-yourself)) (db/delete! conn :team-profile-rel {:profile-id member-id :team-id team-id}) ;; A removed member that owns the organization of this team keeps ;; read-only access to it, so instead of kicking them out we degrade ;; their session to viewer, same as any other role change. (if (nitrate/organization-owner-of-team? cfg member-id team-id) (mbus/pub! msgbus :topic member-id :message {:type :team-role-change :topic member-id :team-id team-id :role :viewer}) (mbus/pub! msgbus :topic member-id :message {:type :team-membership-change :change :removed :team-id team-id :team-name (:name team)})) nil)) ;; --- Mutation: Update Team Photo (declare upload-photo) (declare ^:private update-team-photo) (def ^:private schema:update-team-photo [:map {:title "update-team-photo"} [:team-id ::sm/uuid] [:file media/schema:upload]]) (sv/defmethod ::update-team-photo {::doc/added "1.17" ::sm/params schema:update-team-photo} [cfg {:keys [::rpc/profile-id file] :as params}] ;; Validate incoming mime type (media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"}) (media/validate-media-size! file) (update-team-photo cfg (assoc params :profile-id profile-id))) (defn update-team-photo [{:keys [::db/pool ::sto/storage] :as cfg} {:keys [profile-id team-id] :as params}] (let [team (get-team pool :profile-id profile-id :team-id team-id) photo (profile/upload-photo cfg params)] (check-admin-permissions! pool profile-id team-id) ;; Mark object as touched for make it ellegible for tentative ;; garbage collection. (when-let [id (:photo-id team)] (sto/touch-object! storage id)) ;; Save new photo (db/update! pool :team {:photo-id (:id photo)} {:id team-id}) (assoc team :photo-id (:id photo))))