From d8faff47a830522662bbcef9d5086a5b792448b8 Mon Sep 17 00:00:00 2001
From: Andrey Antukh <niwi@niwi.nz>
Date: Sat, 14 Jan 2023 12:11:45 +0100
Subject: [PATCH] :recycle: Move profile queries and mutations to commands

---
 backend/src/app/auth/oidc.clj                 |   2 +-
 backend/src/app/cli/manage.clj                |   5 +-
 backend/src/app/http/debug.clj                |   2 +-
 backend/src/app/rpc.clj                       |   1 +
 backend/src/app/rpc/commands/auth.clj         |  16 +-
 backend/src/app/rpc/commands/feedback.clj     |   2 +-
 backend/src/app/rpc/commands/ldap.clj         |   2 +-
 backend/src/app/rpc/commands/profile.clj      | 424 ++++++++++++++++++
 backend/src/app/rpc/commands/teams.clj        |  37 +-
 backend/src/app/rpc/commands/verify_token.clj |   2 +-
 backend/src/app/rpc/mutations/profile.clj     | 169 ++-----
 backend/src/app/rpc/queries/profile.clj       |  74 +--
 backend/src/app/srepl/main.clj                |   6 +-
 .../test/backend_tests/rpc_profile_test.clj   |  66 +--
 frontend/src/app/main/data/users.cljs         |  32 +-
 .../app/main/data/workspace/persistence.cljs  |   2 +-
 frontend/src/app/main/repo.cljs               |  10 +
 frontend/src/app/main/ui/routes.cljs          |   2 +-
 18 files changed, 537 insertions(+), 317 deletions(-)
 create mode 100644 backend/src/app/rpc/commands/profile.clj

diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj
index 98c9c0e8f..d6030025a 100644
--- a/backend/src/app/auth/oidc.clj
+++ b/backend/src/app/auth/oidc.clj
@@ -21,7 +21,7 @@
    [app.http.session :as session]
    [app.loggers.audit :as audit]
    [app.main :as-alias main]
-   [app.rpc.queries.profile :as profile]
+   [app.rpc.commands.profile :as profile]
    [app.tokens :as tokens]
    [app.util.json :as json]
    [app.util.time :as dt]
diff --git a/backend/src/app/cli/manage.clj b/backend/src/app/cli/manage.clj
index 8d992a210..a1799434a 100644
--- a/backend/src/app/cli/manage.clj
+++ b/backend/src/app/cli/manage.clj
@@ -11,8 +11,7 @@
    [app.db :as db]
    [app.main :as main]
    [app.rpc.commands.auth :as auth]
-   [app.rpc.mutations.profile :as profile]
-   [app.rpc.queries.profile :refer [get-profile-by-email]]
+   [app.rpc.commands.profile :as profile]
    [clojure.string :as str]
    [clojure.tools.cli :refer [parse-opts]]
    [integrant.core :as ig])
@@ -80,7 +79,7 @@
       (db/with-atomic [conn (:app.db/pool system)]
         (let [email    (or (:email options)
                            (read-from-console {:label "Email:"}))
-              profile  (get-profile-by-email conn email)]
+              profile  (profile/get-profile-by-email conn email)]
           (when-not profile
             (when (pos? (:verbosity options))
               (println "Profile does not exists."))
diff --git a/backend/src/app/http/debug.clj b/backend/src/app/http/debug.clj
index 083ebc9a5..d2cbb6f7a 100644
--- a/backend/src/app/http/debug.clj
+++ b/backend/src/app/http/debug.clj
@@ -17,7 +17,7 @@
    [app.http.session :as session]
    [app.rpc.commands.binfile :as binf]
    [app.rpc.commands.files.create :refer [create-file]]
-   [app.rpc.queries.profile :as profile]
+   [app.rpc.commands.profile :as profile]
    [app.util.blob :as blob]
    [app.util.template :as tmpl]
    [app.util.time :as dt]
diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj
index 257637c25..24fb1637d 100644
--- a/backend/src/app/rpc.clj
+++ b/backend/src/app/rpc.clj
@@ -131,6 +131,7 @@
 
         data       (-> params
                        (assoc ::request-at (dt/now))
+                       (assoc ::session/id (::session/id request))
                        (assoc ::http/request request)
                        (assoc ::cond/key etag)
                        (cond-> (uuid? profile-id)
diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj
index 4a037b18b..17410c425 100644
--- a/backend/src/app/rpc/commands/auth.clj
+++ b/backend/src/app/rpc/commands/auth.clj
@@ -19,10 +19,10 @@
    [app.main :as-alias main]
    [app.rpc :as-alias rpc]
    [app.rpc.climit :as climit]
+   [app.rpc.commands.profile :as profile]
    [app.rpc.commands.teams :as teams]
    [app.rpc.doc :as-alias doc]
    [app.rpc.helpers :as rph]
-   [app.rpc.queries.profile :as profile]
    [app.tokens :as tokens]
    [app.util.services :as sv]
    [app.util.time :as dt]
@@ -52,20 +52,6 @@
                             (str/split #"@" 2))]
       (contains? domains candidate))))
 
-(def ^:private sql:profile-existence
-  "select exists (select * from profile
-                   where email = ?
-                     and deleted_at is null) as val")
-
-(defn check-profile-existence!
-  [conn {:keys [email] :as params}]
-  (let [email  (str/lower email)
-        result (db/exec-one! conn [sql:profile-existence email])]
-    (when (:val result)
-      (ex/raise :type :validation
-                :code :email-already-exists))
-    params))
-
 ;; ---- COMMAND: login with password
 
 (defn login-with-password
diff --git a/backend/src/app/rpc/commands/feedback.clj b/backend/src/app/rpc/commands/feedback.clj
index 6f2f83f98..2f5c2c980 100644
--- a/backend/src/app/rpc/commands/feedback.clj
+++ b/backend/src/app/rpc/commands/feedback.clj
@@ -13,8 +13,8 @@
    [app.db :as db]
    [app.emails :as eml]
    [app.rpc :as-alias rpc]
+   [app.rpc.commands.profile :as profile]
    [app.rpc.doc :as-alias doc]
-   [app.rpc.queries.profile :as profile]
    [app.util.services :as sv]
    [clojure.spec.alpha :as s]))
 
diff --git a/backend/src/app/rpc/commands/ldap.clj b/backend/src/app/rpc/commands/ldap.clj
index e4367769e..921e7b4c7 100644
--- a/backend/src/app/rpc/commands/ldap.clj
+++ b/backend/src/app/rpc/commands/ldap.clj
@@ -15,9 +15,9 @@
    [app.main :as-alias main]
    [app.rpc :as-alias rpc]
    [app.rpc.commands.auth :as auth]
+   [app.rpc.commands.profile :as profile]
    [app.rpc.doc :as-alias doc]
    [app.rpc.helpers :as rph]
-   [app.rpc.queries.profile :as profile]
    [app.tokens :as tokens]
    [app.util.services :as sv]
    [clojure.spec.alpha :as s]))
diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj
new file mode 100644
index 000000000..bb474ca08
--- /dev/null
+++ b/backend/src/app/rpc/commands/profile.clj
@@ -0,0 +1,424 @@
+;; 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
+
+(ns app.rpc.commands.profile
+  (:require
+   [app.auth :as auth]
+   [app.common.data :as d]
+   [app.common.exceptions :as ex]
+   [app.common.spec :as us]
+   [app.common.uuid :as uuid]
+   [app.config :as cf]
+   [app.db :as db]
+   [app.emails :as eml]
+   [app.http.session :as session]
+   [app.loggers.audit :as audit]
+   [app.main :as-alias main]
+   [app.media :as media]
+   [app.rpc :as-alias rpc]
+   [app.rpc.climit :as climit]
+   [app.rpc.doc :as-alias doc]
+   [app.rpc.helpers :as rph]
+   [app.storage :as sto]
+   [app.tokens :as tokens]
+   [app.util.services :as sv]
+   [app.util.time :as dt]
+   [app.worker :as-alias wrk]
+   [clojure.spec.alpha :as s]
+   [cuerdas.core :as str]
+   [promesa.core :as p]
+   [promesa.exec :as px]))
+
+(declare decode-row)
+(declare get-profile)
+(declare strip-private-attrs)
+(declare filter-props)
+(declare check-profile-existence!)
+
+;; --- QUERY: Get profile (own)
+
+(s/def ::get-profile
+  (s/keys :opt [::rpc/profile-id]))
+
+(sv/defmethod ::get-profile
+  {::rpc/auth false
+   ::doc/added "1.18"}
+  [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id]}]
+  ;; We need to return the anonymous profile object in two cases, when
+  ;; no profile-id is in session, and when db call raises not found. In all other
+  ;; cases we need to reraise the exception.
+  (try
+    (-> (get-profile pool profile-id)
+        (strip-private-attrs)
+        (update :props filter-props))
+    (catch Throwable _
+      {:id uuid/zero :fullname "Anonymous User"})))
+
+(defn get-profile
+  "Get profile by id. Throws not-found exception if no profile found."
+  [conn id & {:as attrs}]
+  (-> (db/get-by-id conn :profile id attrs)
+      (decode-row)))
+
+
+;; --- MUTATION: Update Profile (own)
+
+(s/def ::email ::us/email)
+(s/def ::fullname ::us/not-empty-string)
+(s/def ::lang ::us/string)
+(s/def ::theme ::us/string)
+
+(s/def ::update-profile
+  (s/keys :req [::rpc/profile-id]
+          :req-un [::fullname]
+          :opt-un [::lang ::theme]))
+
+(sv/defmethod ::update-profile
+  {::doc/added "1.0"}
+  [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id fullname lang theme] :as params}]
+  (db/with-atomic [conn pool]
+    ;; NOTE: we need to retrieve the profile independently if we use
+    ;; it or not for explicit locking and avoid concurrent updates of
+    ;; the same row/object.
+    (let [profile (-> (db/get-by-id conn :profile profile-id ::db/for-update? true)
+                      (decode-row))
+
+          ;; Update the profile map with direct params
+          profile (-> profile
+                      (assoc :fullname fullname)
+                      (assoc :lang lang)
+                      (assoc :theme theme))
+          ]
+
+      (db/update! conn :profile
+                  {:fullname fullname
+                   :lang lang
+                   :theme theme
+                   :props (db/tjson (:props profile))}
+                  {:id profile-id})
+
+      (-> profile
+          (strip-private-attrs)
+          (d/without-nils)
+          (rph/with-meta {::audit/props (audit/profile->props profile)})))))
+
+
+;; --- MUTATION: Update Password
+
+(declare validate-password!)
+(declare update-profile-password!)
+(declare invalidate-profile-session!)
+
+(s/def ::password ::us/not-empty-string)
+(s/def ::old-password ::us/not-empty-string)
+
+(s/def ::update-profile-password
+  (s/keys :req [::rpc/profile-id]
+          :req-un [::password ::old-password]))
+
+(sv/defmethod ::update-profile-password
+  {::climit/queue :auth}
+  [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id password] :as params}]
+  (db/with-atomic [conn pool]
+    (let [profile    (validate-password! conn (assoc params :profile-id profile-id))
+          session-id (::session/id params)]
+
+      (when (= (str/lower (:email profile))
+               (str/lower (:password params)))
+        (ex/raise :type :validation
+                  :code :email-as-password
+                  :hint "you can't use your email as password"))
+
+      (update-profile-password! conn (assoc profile :password password))
+      (invalidate-profile-session! conn profile-id session-id)
+      nil)))
+
+(defn- invalidate-profile-session!
+  "Removes all sessions except the current one."
+  [conn profile-id session-id]
+  (let [sql "delete from http_session where profile_id = ? and id != ?"]
+    (:next.jdbc/update-count (db/exec-one! conn [sql profile-id session-id]))))
+
+(defn- validate-password!
+  [conn {:keys [profile-id old-password] :as params}]
+  (let [profile (db/get-by-id conn :profile profile-id ::db/for-update? true)]
+    (when-not (:valid (auth/verify-password old-password (:password profile)))
+      (ex/raise :type :validation
+                :code :old-password-not-match))
+    profile))
+
+(defn update-profile-password!
+  [conn {:keys [id password] :as profile}]
+  (db/update! conn :profile
+              {:password (auth/derive-password password)}
+              {:id id}))
+
+;; --- MUTATION: Update Photo
+
+(declare upload-photo)
+(declare update-profile-photo)
+
+(s/def ::file ::media/upload)
+(s/def ::update-profile-photo
+  (s/keys :req [::rpc/profile-id]
+          :req-un [::file]))
+
+(sv/defmethod ::update-profile-photo
+  [cfg {:keys [::rpc/profile-id file] :as params}]
+  ;; Validate incoming mime type
+  (media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
+  (let [cfg (update cfg ::sto/storage media/configure-assets-storage)]
+    (update-profile-photo cfg (assoc params :profile-id profile-id))))
+
+;; TODO: reimplement it without p/let
+
+(defn update-profile-photo
+  [{:keys [::db/pool ::sto/storage ::wrk/executor] :as cfg} {:keys [profile-id file] :as params}]
+  (letfn [(on-uploaded [photo]
+            (let [profile (db/get-by-id pool :profile profile-id ::db/for-update? true)]
+
+              ;; Schedule deletion of old photo
+              (when-let [id (:photo-id profile)]
+                (sto/touch-object! storage id))
+
+              ;; Save new photo
+              (db/update! pool :profile
+                          {:photo-id (:id photo)}
+                          {:id profile-id})
+
+              (-> (rph/wrap)
+                  (rph/with-meta {::audit/replace-props
+                                  {:file-name (:filename file)
+                                   :file-size (:size file)
+                                   :file-path (str (:path file))
+                                   :file-mtype (:mtype file)}}))))]
+    (->> (upload-photo cfg params)
+         (p/fmap executor on-uploaded))))
+
+(defn upload-photo
+  [{:keys [::sto/storage ::wrk/executor climit] :as cfg} {:keys [file]}]
+  (letfn [(get-info [content]
+            (climit/with-dispatch (:process-image climit)
+              (media/run {:cmd :info :input content})))
+
+          (generate-thumbnail [info]
+            (climit/with-dispatch (:process-image climit)
+              (media/run {:cmd :profile-thumbnail
+                          :format :jpeg
+                          :quality 85
+                          :width 256
+                          :height 256
+                          :input info})))
+
+          ;; Function responsible of calculating cryptographyc hash of
+          ;; the provided data.
+          (calculate-hash [data]
+            (px/with-dispatch executor
+              (sto/calculate-hash data)))]
+
+    (p/let [info    (get-info file)
+            thumb   (generate-thumbnail info)
+            hash    (calculate-hash (:data thumb))
+            content (-> (sto/content (:data thumb) (:size thumb))
+                        (sto/wrap-with-hash hash))]
+      (sto/put-object! storage {::sto/content content
+                                ::sto/deduplicate? true
+                                :bucket "profile"
+                                :content-type (:mtype thumb)}))))
+
+
+;; --- MUTATION: Request Email Change
+
+(declare ^:private request-email-change!)
+(declare ^:private change-email-immediately!)
+
+(s/def ::request-email-change
+  (s/keys :req [::rpc/profile-id]
+          :req-un [::email]))
+
+(sv/defmethod ::request-email-change
+  [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id email] :as params}]
+  (db/with-atomic [conn pool]
+    (let [profile (db/get-by-id conn :profile profile-id)
+          cfg     (assoc cfg ::conn conn)
+          params  (assoc params
+                         :profile profile
+                         :email (str/lower email))]
+      (if (contains? cf/flags :smtp)
+        (request-email-change! cfg params)
+        (change-email-immediately! cfg params)))))
+
+(defn- change-email-immediately!
+  [{:keys [::conn]} {:keys [profile email] :as params}]
+  (when (not= email (:email profile))
+    (check-profile-existence! conn params))
+
+  (db/update! conn :profile
+              {:email email}
+              {:id (:id profile)})
+
+  {:changed true})
+
+(defn- request-email-change!
+  [{:keys [::conn] :as cfg} {:keys [profile email] :as params}]
+  (let [token   (tokens/generate (::main/props cfg)
+                                 {:iss :change-email
+                                  :exp (dt/in-future "15m")
+                                  :profile-id (:id profile)
+                                  :email email})
+        ptoken  (tokens/generate (::main/props cfg)
+                                 {:iss :profile-identity
+                                  :profile-id (:id profile)
+                                  :exp (dt/in-future {:days 30})})]
+
+    (when (not= email (:email profile))
+      (check-profile-existence! conn params))
+
+    (when-not (eml/allow-send-emails? conn profile)
+      (ex/raise :type :validation
+                :code :profile-is-muted
+                :hint "looks like the profile has reported repeatedly as spam or has permanent bounces."))
+
+    (when (eml/has-bounce-reports? conn email)
+      (ex/raise :type :validation
+                :code :email-has-permanent-bounces
+                :hint "looks like the email you invite has been repeatedly reported as spam or permanent bounce"))
+
+    (eml/send! {::eml/conn conn
+                ::eml/factory eml/change-email
+                :public-uri (cf/get :public-uri)
+                :to (:email profile)
+                :name (:fullname profile)
+                :pending-email email
+                :token token
+                :extra-data ptoken})
+    nil))
+
+
+;; --- MUTATION: Update Profile Props
+
+(s/def ::props map?)
+(s/def ::update-profile-props
+  (s/keys :req [::rpc/profile-id]
+          :req-un [::props]))
+
+(sv/defmethod ::update-profile-props
+  [{:keys [::db/pool]} {:keys [::rpc/profile-id props]}]
+  (db/with-atomic [conn pool]
+    (let [profile (get-profile conn profile-id ::db/for-update? true)
+          props   (reduce-kv (fn [props k v]
+                               ;; We don't accept namespaced keys
+                               (if (simple-ident? k)
+                                 (if (nil? v)
+                                   (dissoc props k)
+                                   (assoc props k v))
+                                 props))
+                             (:props profile)
+                             props)]
+
+      (db/update! conn :profile
+                  {:props (db/tjson props)}
+                  {:id profile-id})
+
+      (filter-props props))))
+
+
+;; --- MUTATION: Delete Profile
+
+(declare ^:private get-owned-teams-with-participants)
+
+(s/def ::delete-profile
+  (s/keys :req [::rpc/profile-id]))
+
+(sv/defmethod ::delete-profile
+  [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
+  (db/with-atomic [conn pool]
+    (let [teams      (get-owned-teams-with-participants conn profile-id)
+          deleted-at (dt/now)]
+
+      ;; If we found owned teams with participants, we don't allow
+      ;; delete profile until the user properly transfer ownership or
+      ;; explicitly removes all participants from the team
+      (when (some pos? (map :participants teams))
+        (ex/raise :type :validation
+                  :code :owner-teams-with-people
+                  :hint "The user need to transfer ownership of owned teams."
+                  :context {:teams (mapv :id teams)}))
+
+      (doseq [{:keys [id]} teams]
+        (db/update! conn :team
+                    {:deleted-at deleted-at}
+                    {:id id}))
+
+      (db/update! conn :profile
+                  {:deleted-at deleted-at}
+                  {:id profile-id})
+
+      (rph/with-transform {} (session/delete-fn cfg)))))
+
+
+;; --- HELPERS
+
+(def sql:owned-teams
+  "with owner_teams as (
+      select tpr.team_id as id
+        from team_profile_rel as tpr
+       where tpr.is_owner is true
+         and tpr.profile_id = ?
+   )
+   select tpr.team_id as id,
+          count(tpr.profile_id) - 1 as participants
+     from team_profile_rel as tpr
+    where tpr.team_id in (select id from owner_teams)
+      and tpr.profile_id != ?
+    group by 1")
+
+(defn- get-owned-teams-with-participants
+  [conn profile-id]
+  (db/exec! conn [sql:owned-teams profile-id profile-id]))
+
+(def ^:private sql:profile-existence
+  "select exists (select * from profile
+                   where email = ?
+                     and deleted_at is null) as val")
+
+(defn check-profile-existence!
+  [conn {:keys [email] :as params}]
+  (let [email  (str/lower email)
+        result (db/exec-one! conn [sql:profile-existence email])]
+    (when (:val result)
+      (ex/raise :type :validation
+                :code :email-already-exists))
+    params))
+
+(def ^:private sql:profile-by-email
+  "select p.* from profile as p
+    where p.email = ?
+      and (p.deleted_at is null or
+           p.deleted_at > now())")
+
+(defn get-profile-by-email
+  "Returns a profile looked up by email or `nil` if not match found."
+  [conn email]
+  (->> (db/exec! conn [sql:profile-by-email (str/lower email)])
+       (map decode-row)
+       (first)))
+
+(defn strip-private-attrs
+  "Only selects a publicly visible profile attrs."
+  [row]
+  (dissoc row :password :deleted-at))
+
+(defn filter-props
+  "Removes all namespace qualified props from `props` attr."
+  [props]
+  (into {} (filter (fn [[k _]] (simple-ident? k))) props))
+
+(defn decode-row
+  [{:keys [props] :as row}]
+  (cond-> row
+    (db/pgobject? props "jsonb")
+    (assoc :props (db/decode-transit-pgobject props))))
diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj
index 24a490564..f70aa3d01 100644
--- a/backend/src/app/rpc/commands/teams.clj
+++ b/backend/src/app/rpc/commands/teams.clj
@@ -18,11 +18,10 @@
    [app.main :as-alias main]
    [app.media :as media]
    [app.rpc :as-alias rpc]
-   [app.rpc.climit :as climit]
+   [app.rpc.commands.profile :as profile]
    [app.rpc.doc :as-alias doc]
    [app.rpc.helpers :as rph]
    [app.rpc.permissions :as perms]
-   [app.rpc.queries.profile :as profile]
    [app.rpc.quotes :as quotes]
    [app.storage :as sto]
    [app.tokens :as tokens]
@@ -572,7 +571,7 @@
 
 ;; --- Mutation: Update Team Photo
 
-(declare ^:private upload-photo)
+(declare upload-photo)
 (declare ^:private update-team-photo)
 
 (s/def ::file ::media/upload)
@@ -592,7 +591,7 @@
   [{:keys [::db/pool ::sto/storage ::wrk/executor] :as cfg} {:keys [profile-id team-id] :as params}]
   (p/let [team  (px/with-dispatch executor
                   (retrieve-team pool profile-id team-id))
-          photo (upload-photo cfg params)]
+          photo (profile/upload-photo cfg params)]
 
     ;; Mark object as touched for make it ellegible for tentative
     ;; garbage collection.
@@ -606,36 +605,6 @@
 
     (assoc team :photo-id (:id photo))))
 
-(defn upload-photo
-  [{:keys [::sto/storage ::wrk/executor climit] :as cfg} {:keys [file]}]
-  (letfn [(get-info [content]
-            (climit/with-dispatch (:process-image climit)
-              (media/run {:cmd :info :input content})))
-
-          (generate-thumbnail [info]
-            (climit/with-dispatch (:process-image climit)
-              (media/run {:cmd :profile-thumbnail
-                          :format :jpeg
-                          :quality 85
-                          :width 256
-                          :height 256
-                          :input info})))
-
-          ;; Function responsible of calculating cryptographyc hash of
-          ;; the provided data.
-          (calculate-hash [data]
-            (px/with-dispatch executor
-              (sto/calculate-hash data)))]
-
-    (p/let [info    (get-info file)
-            thumb   (generate-thumbnail info)
-            hash    (calculate-hash (:data thumb))
-            content (-> (sto/content (:data thumb) (:size thumb))
-                        (sto/wrap-with-hash hash))]
-      (sto/put-object! storage {::sto/content content
-                                ::sto/deduplicate? true
-                                :bucket "profile"
-                                :content-type (:mtype thumb)}))))
 
 ;; --- Mutation: Create Team Invitation
 
diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj
index ba9f6b98a..914116ee9 100644
--- a/backend/src/app/rpc/commands/verify_token.clj
+++ b/backend/src/app/rpc/commands/verify_token.clj
@@ -13,10 +13,10 @@
    [app.loggers.audit :as audit]
    [app.main :as-alias main]
    [app.rpc :as-alias rpc]
+   [app.rpc.commands.profile :as profile]
    [app.rpc.commands.teams :as teams]
    [app.rpc.doc :as-alias doc]
    [app.rpc.helpers :as rph]
-   [app.rpc.queries.profile :as profile]
    [app.rpc.quotes :as quotes]
    [app.tokens :as tokens]
    [app.tokens.spec.team-invitation :as-alias spec.team-invitation]
diff --git a/backend/src/app/rpc/mutations/profile.clj b/backend/src/app/rpc/mutations/profile.clj
index 4c70f4cfc..406e029cb 100644
--- a/backend/src/app/rpc/mutations/profile.clj
+++ b/backend/src/app/rpc/mutations/profile.clj
@@ -6,33 +6,23 @@
 
 (ns app.rpc.mutations.profile
   (:require
-   [app.auth :as auth]
    [app.common.data :as d]
    [app.common.exceptions :as ex]
    [app.common.spec :as us]
    [app.config :as cf]
    [app.db :as db]
-   [app.emails :as eml]
    [app.http.session :as session]
    [app.loggers.audit :as audit]
-   [app.main :as-alias main]
    [app.media :as media]
-   [app.rpc :as-alias rpc]
    [app.rpc.climit :as-alias climit]
-   [app.rpc.commands.auth :as cmd.auth]
-   [app.rpc.commands.teams :as teams]
+   [app.rpc.commands.profile :as profile]
    [app.rpc.doc :as-alias doc]
    [app.rpc.helpers :as rph]
-   [app.rpc.queries.profile :as profile]
    [app.storage :as sto]
-   [app.tokens :as tokens]
    [app.util.services :as sv]
    [app.util.time :as dt]
-   [app.worker :as-alias wrk]
    [clojure.spec.alpha :as s]
-   [cuerdas.core :as str]
-   [promesa.core :as p]
-   [promesa.exec :as px]))
+   [cuerdas.core :as str]))
 
 ;; --- Helpers & Specs
 
@@ -52,7 +42,8 @@
           :opt-un [::lang ::theme]))
 
 (sv/defmethod ::update-profile
-  {::doc/added "1.0"}
+  {::doc/added "1.0"
+   ::doc/deprecated "1.18"}
   [{:keys [::db/pool] :as cfg} {:keys [profile-id fullname lang theme] :as params}]
   (db/with-atomic [conn pool]
     ;; NOTE: we need to retrieve the profile independently if we use
@@ -76,156 +67,68 @@
                   {:id profile-id})
 
       (-> profile
-          profile/strip-private-attrs
-          d/without-nils
+          (profile/strip-private-attrs)
+          (d/without-nils)
           (rph/with-meta {::audit/props (audit/profile->props profile)})))))
 
 
 ;; --- MUTATION: Update Password
 
-(declare validate-password!)
-(declare update-profile-password!)
-(declare invalidate-profile-session!)
-
 (s/def ::update-profile-password
   (s/keys :req-un [::profile-id ::password ::old-password]))
 
 (sv/defmethod ::update-profile-password
-  {::climit/queue :auth}
+  {::climit/queue :auth
+   ::doc/added "1.0"
+   ::doc/deprecated "1.18"}
   [{:keys [::db/pool] :as cfg} {:keys [password] :as params}]
   (db/with-atomic [conn pool]
-    (let [profile    (validate-password! conn params)
-          session-id (::rpc/session-id params)]
+    (let [profile    (#'profile/validate-password! conn params)
+          session-id (::session/id params)]
       (when (= (str/lower (:email profile))
                (str/lower (:password params)))
         (ex/raise :type :validation
                   :code :email-as-password
                   :hint "you can't use your email as password"))
-      (update-profile-password! conn (assoc profile :password password))
-      (invalidate-profile-session! conn (:id profile) session-id)
+      (profile/update-profile-password! conn (assoc profile :password password))
+      (#'profile/invalidate-profile-session! conn (:id profile) session-id)
       nil)))
 
-(defn- invalidate-profile-session!
-  "Removes all sessions except the current one."
-  [conn profile-id session-id]
-  (let [sql "delete from http_session where profile_id = ? and id != ?"]
-    (:next.jdbc/update-count (db/exec-one! conn [sql profile-id session-id]))))
-
-(defn- validate-password!
-  [conn {:keys [profile-id old-password] :as params}]
-  (let [profile (db/get-by-id conn :profile profile-id)]
-    (when-not (:valid (auth/verify-password old-password (:password profile)))
-      (ex/raise :type :validation
-                :code :old-password-not-match))
-    profile))
-
-(defn update-profile-password!
-  [conn {:keys [id password] :as profile}]
-  (db/update! conn :profile
-              {:password (auth/derive-password password)}
-              {:id id}))
 
 ;; --- MUTATION: Update Photo
 
-(declare update-profile-photo)
-
 (s/def ::file ::media/upload)
 (s/def ::update-profile-photo
   (s/keys :req-un [::profile-id ::file]))
 
 (sv/defmethod ::update-profile-photo
+  {::doc/added "1.0"
+   ::doc/deprecated "1.18"}
   [cfg {:keys [file] :as params}]
   ;; Validate incoming mime type
   (media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"})
   (let [cfg (update cfg ::sto/storage media/configure-assets-storage)]
-    (update-profile-photo cfg params)))
-
-(defn update-profile-photo
-  [{:keys [::db/pool ::sto/storage ::wrk/executor] :as cfg} {:keys [profile-id file] :as params}]
-  (p/let [profile (px/with-dispatch executor
-                    (db/get-by-id pool :profile profile-id))
-          photo   (teams/upload-photo cfg params)]
-
-    ;; Schedule deletion of old photo
-    (when-let [id (:photo-id profile)]
-      (sto/touch-object! storage id))
-
-    ;; Save new photo
-    (db/update! pool :profile
-                {:photo-id (:id photo)}
-                {:id profile-id})
-
-    (-> (rph/wrap)
-        (rph/with-meta {::audit/replace-props
-                        {:file-name (:filename file)
-                         :file-size (:size file)
-                         :file-path (str (:path file))
-                         :file-mtype (:mtype file)}}))))
+    (profile/update-profile-photo cfg params)))
 
 ;; --- MUTATION: Request Email Change
 
-(declare request-email-change)
-(declare change-email-immediately)
-
 (s/def ::request-email-change
   (s/keys :req-un [::email]))
 
 (sv/defmethod ::request-email-change
+  {::doc/added "1.0"
+   ::doc/deprecated "1.18"}
   [{:keys [::db/pool] :as cfg} {:keys [profile-id email] :as params}]
   (db/with-atomic [conn pool]
     (let [profile (db/get-by-id conn :profile profile-id)
-          cfg     (assoc cfg :conn conn)
+          cfg     (assoc cfg ::profile/conn conn)
           params  (assoc params
                          :profile profile
                          :email (str/lower email))]
+
       (if (contains? cf/flags :smtp)
-        (request-email-change cfg params)
-        (change-email-immediately cfg params)))))
-
-(defn- change-email-immediately
-  [{:keys [conn]} {:keys [profile email] :as params}]
-  (when (not= email (:email profile))
-    (cmd.auth/check-profile-existence! conn params))
-  (db/update! conn :profile
-              {:email email}
-              {:id (:id profile)})
-  {:changed true})
-
-(defn- request-email-change
-  [{:keys [conn] :as cfg} {:keys [profile email] :as params}]
-  (let [token   (tokens/generate (::main/props cfg)
-                                 {:iss :change-email
-                                  :exp (dt/in-future "15m")
-                                  :profile-id (:id profile)
-                                  :email email})
-        ptoken  (tokens/generate (::main/props cfg)
-                                 {:iss :profile-identity
-                                  :profile-id (:id profile)
-                                  :exp (dt/in-future {:days 30})})]
-
-    (when (not= email (:email profile))
-      (cmd.auth/check-profile-existence! conn params))
-
-    (when-not (eml/allow-send-emails? conn profile)
-      (ex/raise :type :validation
-                :code :profile-is-muted
-                :hint "looks like the profile has reported repeatedly as spam or has permanent bounces."))
-
-    (when (eml/has-bounce-reports? conn email)
-      (ex/raise :type :validation
-                :code :email-has-permanent-bounces
-                :hint "looks like the email you invite has been repeatedly reported as spam or permanent bounce"))
-
-    (eml/send! {::eml/conn conn
-                ::eml/factory eml/change-email
-                :public-uri (cf/get :public-uri)
-                :to (:email profile)
-                :name (:fullname profile)
-                :pending-email email
-                :token token
-                :extra-data ptoken})
-    nil))
-
+        (#'profile/request-email-change! cfg params)
+        (#'profile/change-email-immediately! cfg params)))))
 
 ;; --- MUTATION: Update Profile Props
 
@@ -234,6 +137,8 @@
   (s/keys :req-un [::profile-id ::props]))
 
 (sv/defmethod ::update-profile-props
+  {::doc/added "1.0"
+   ::doc/deprecated "1.18"}
   [{:keys [::db/pool] :as cfg} {:keys [profile-id props]}]
   (db/with-atomic [conn pool]
     (let [profile (profile/get-profile conn profile-id ::db/for-update? true)
@@ -256,17 +161,15 @@
 
 ;; --- MUTATION: Delete Profile
 
-(declare get-owned-teams-with-participants)
-(declare check-can-delete-profile!)
-(declare mark-profile-as-deleted!)
-
 (s/def ::delete-profile
   (s/keys :req-un [::profile-id]))
 
 (sv/defmethod ::delete-profile
+  {::doc/added "1.0"
+   ::doc/deprecated "1.18"}
   [{:keys [::db/pool] :as cfg} {:keys [profile-id] :as params}]
   (db/with-atomic [conn pool]
-    (let [teams      (get-owned-teams-with-participants conn profile-id)
+    (let [teams      (#'profile/get-owned-teams-with-participants conn profile-id)
           deleted-at (dt/now)]
 
       ;; If we found owned teams with participants, we don't allow
@@ -288,21 +191,3 @@
                   {:id profile-id})
 
       (rph/with-transform {} (session/delete-fn cfg)))))
-
-(def sql:owned-teams
-  "with owner_teams as (
-      select tpr.team_id as id
-        from team_profile_rel as tpr
-       where tpr.is_owner is true
-         and tpr.profile_id = ?
-   )
-   select tpr.team_id as id,
-          count(tpr.profile_id) - 1 as participants
-     from team_profile_rel as tpr
-    where tpr.team_id in (select id from owner_teams)
-      and tpr.profile_id != ?
-    group by 1")
-
-(defn- get-owned-teams-with-participants
-  [conn profile-id]
-  (db/exec! conn [sql:owned-teams profile-id profile-id]))
diff --git a/backend/src/app/rpc/queries/profile.clj b/backend/src/app/rpc/queries/profile.clj
index 6bfec336f..86b7ee015 100644
--- a/backend/src/app/rpc/queries/profile.clj
+++ b/backend/src/app/rpc/queries/profile.clj
@@ -6,81 +6,27 @@
 
 (ns app.rpc.queries.profile
   (:require
-   [app.common.spec :as us]
    [app.common.uuid :as uuid]
    [app.db :as db]
    [app.rpc :as-alias rpc]
+   [app.rpc.commands.profile :as profile]
+   [app.rpc.doc :as-alias doc]
    [app.util.services :as sv]
-   [clojure.spec.alpha :as s]
-   [cuerdas.core :as str]))
+   [clojure.spec.alpha :as s]))
 
-;; --- Helpers & Specs
-
-(s/def ::email ::us/email)
-(s/def ::fullname ::us/string)
-(s/def ::old-password ::us/string)
-(s/def ::password ::us/string)
-(s/def ::path ::us/string)
-(s/def ::user ::us/uuid)
-(s/def ::profile-id ::us/uuid)
-(s/def ::theme ::us/string)
-
-;; --- Query: Profile (own)
-
-(declare decode-row)
-(declare get-profile)
-(declare strip-private-attrs)
-(declare filter-props)
-
-(s/def ::profile
-  (s/keys :opt-un [::profile-id]))
+(s/def ::profile ::profile/get-profile)
 
 (sv/defmethod ::profile
-  {::rpc/auth false}
+  {::rpc/auth false
+   ::doc/added "1.0"
+   ::doc/deprecated "1.18"}
   [{:keys [::db/pool] :as cfg} {:keys [profile-id]}]
   ;; We need to return the anonymous profile object in two cases, when
   ;; no profile-id is in session, and when db call raises not found. In all other
   ;; cases we need to reraise the exception.
   (try
-    (-> (get-profile pool profile-id)
-        (strip-private-attrs)
-        (update :props filter-props))
+    (-> (profile/get-profile pool profile-id)
+        (profile/strip-private-attrs)
+        (update :props profile/filter-props))
     (catch Throwable _
       {:id uuid/zero :fullname "Anonymous User"})))
-
-(defn get-profile
-  "Get profile by id. Throws not-found exception if no profile found."
-  [conn id & {:as attrs}]
-  (-> (db/get-by-id conn :profile id attrs)
-      (decode-row)))
-
-(def ^:private sql:profile-by-email
-  "select p.* from profile as p
-    where p.email = ?
-      and (p.deleted_at is null or
-           p.deleted_at > now())")
-
-(defn get-profile-by-email
-  "Returns a profile looked up by email or `nil` if not match found."
-  [conn email]
-  (->> (db/exec! conn [sql:profile-by-email (str/lower email)])
-       (map decode-row)
-       (first)))
-
-;; --- HELPERS
-
-(defn strip-private-attrs
-  "Only selects a publicly visible profile attrs."
-  [row]
-  (dissoc row :password :deleted-at))
-
-(defn filter-props
-  "Removes all namespace qualified props from `props` attr."
-  [props]
-  (into {} (filter (fn [[k _]] (simple-ident? k))) props))
-
-(defn decode-row
-  [{:keys [props] :as row}]
-  (cond-> row
-    (db/pgobject? props "jsonb")
-    (assoc :props (db/decode-transit-pgobject props))))
diff --git a/backend/src/app/srepl/main.clj b/backend/src/app/srepl/main.clj
index 1561626d8..d9b03a099 100644
--- a/backend/src/app/srepl/main.clj
+++ b/backend/src/app/srepl/main.clj
@@ -12,8 +12,8 @@
    [app.common.pprint :as p]
    [app.common.spec :as us]
    [app.db :as db]
-   [app.rpc.commands.auth :as cmd.auth]
-   [app.rpc.queries.profile :as profile]
+   [app.rpc.commands.auth :as auth]
+   [app.rpc.commands.profile :as profile]
    [app.srepl.fixes :as f]
    [app.srepl.helpers :as h]
    [app.util.blob :as blob]
@@ -73,7 +73,7 @@
         pool    (:app.db/pool system)
         profile (profile/get-profile-by-email pool email)]
 
-    (cmd.auth/send-email-verification! pool sprops profile)
+    (auth/send-email-verification! pool sprops profile)
     :email-sent))
 
 (defn mark-profile-as-active!
diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj
index 75f83b7c3..604a27943 100644
--- a/backend/test/backend_tests/rpc_profile_test.clj
+++ b/backend/test/backend_tests/rpc_profile_test.clj
@@ -6,14 +6,14 @@
 
 (ns backend-tests.rpc-profile-test
   (:require
-   [backend-tests.helpers :as th]
    [app.common.uuid :as uuid]
    [app.config :as cf]
    [app.db :as db]
+   [app.rpc :as-alias rpc]
    [app.rpc.commands.auth :as cauth]
-   [app.rpc.mutations.profile :as profile]
    [app.tokens :as tokens]
    [app.util.time :as dt]
+   [backend-tests.helpers :as th]
    [clojure.java.io :as io]
    [clojure.test :as t]
    [cuerdas.core :as str]
@@ -67,9 +67,9 @@
 (t/deftest profile-query-and-manipulation
   (let [profile (th/create-profile* 1)]
     (t/testing "query profile"
-      (let [data {::th/type :profile
-                  :profile-id (:id profile)}
-            out  (th/query! data)]
+      (let [data {::th/type :get-profile
+                  ::rpc/profile-id (:id profile)}
+            out  (th/command! data)]
 
         ;; (th/print-result! out)
         (t/is (nil? (:error out)))
@@ -82,20 +82,20 @@
     (t/testing "update profile"
       (let [data (assoc profile
                         ::th/type :update-profile
-                        :profile-id (:id profile)
+                        ::rpc/profile-id (:id profile)
                         :fullname "Full Name"
                         :lang "en"
                         :theme "dark")
-            out  (th/mutation! data)]
+            out  (th/command! data)]
 
         ;; (th/print-result! out)
         (t/is (nil? (:error out)))
         (t/is (map? (:result out)))))
 
     (t/testing "query profile after update"
-      (let [data {::th/type :profile
-                  :profile-id (:id profile)}
-            out  (th/query! data)]
+      (let [data {::th/type :get-profile
+                  ::rpc/profile-id (:id profile)}
+            out  (th/command! data)]
 
         #_(th/print-result! out)
         (t/is (nil? (:error out)))
@@ -107,12 +107,12 @@
 
     (t/testing "update photo"
       (let [data {::th/type :update-profile-photo
-                  :profile-id (:id profile)
+                  ::rpc/profile-id (:id profile)
                   :file {:filename "sample.jpg"
                          :size 123123
                          :path (th/tempfile "backend_tests/test_files/sample.jpg")
                          :mtype "image/jpeg"}}
-            out  (th/mutation! data)]
+            out  (th/command! data)]
 
         ;; (th/print-result! out)
         (t/is (nil? (:error out)))))
@@ -131,8 +131,8 @@
 
     ;; Request profile to be deleted
     (let [params {::th/type :delete-profile
-                  :profile-id (:id prof)}
-          out    (th/mutation! params)]
+                  ::rpc/profile-id (:id prof)}
+          out    (th/command! params)]
       (t/is (nil? (:error out))))
 
     ;; query files after profile soft deletion
@@ -154,9 +154,9 @@
       (t/is (dt/instant? (:deleted-at row))))
 
     ;; query profile after delete
-    (let [params {::th/type :profile
-                  :profile-id (:id prof)}
-          out    (th/query! params)]
+    (let [params {::th/type :get-profile
+                  ::rpc/profile-id (:id prof)}
+          out    (th/command! params)]
       ;; (th/print-result! out)
       (let [result (:result out)]
         (t/is (= uuid/zero (:id result)))))))
@@ -174,7 +174,7 @@
   (let [data  {::th/type :prepare-register-profile
                :email "user@example.com"
                :password "foobar"}
-        out   (th/mutation! data)
+        out   (th/command! data)
         token (get-in out [:result :token])]
     (t/is (string? token))
 
@@ -183,7 +183,7 @@
     (let [data  {::th/type :register-profile
                  :fullname "foobar"
                  :accept-terms-and-privacy true}
-          out   (th/mutation! data)]
+          out   (th/command! data)]
       (let [error (:error out)]
         (t/is (th/ex-info? error))
         (t/is (th/ex-of-type? error :validation))
@@ -195,7 +195,7 @@
                  :fullname "foobar"
                  :accept-terms-and-privacy true
                  :accept-newsletter-subscription true}]
-      (let [{:keys [result error]} (th/mutation! data)]
+      (let [{:keys [result error]} (th/command! data)]
         (t/is (nil? error))))
     ))
 
@@ -413,11 +413,11 @@
     (let [profile (th/create-profile* 1)
           pool    (:app.db/pool th/*system*)
           data    {::th/type :request-email-change
-                   :profile-id (:id profile)
+                   ::rpc/profile-id (:id profile)
                    :email "user1@example.com"}]
 
       ;; without complaints
-      (let [out (th/mutation! data)]
+      (let [out (th/command! data)]
         ;; (th/print-result! out)
         (t/is (nil? (:result out)))
         (let [mock @mock]
@@ -426,14 +426,14 @@
 
       ;; with complaints
       (th/create-global-complaint-for pool {:type :complaint :email (:email data)})
-      (let [out (th/mutation! data)]
+      (let [out (th/command! data)]
         ;; (th/print-result! out)
         (t/is (nil? (:result out)))
         (t/is (= 2 (:call-count @mock))))
 
       ;; with bounces
       (th/create-global-complaint-for pool {:type :bounce :email (:email data)})
-      (let [out   (th/mutation! data)
+      (let [out   (th/command! data)
             error (:error out)]
         ;; (th/print-result! out)
         (t/is (th/ex-info? error))
@@ -448,9 +448,9 @@
       (let [profile (th/create-profile* 1)
             pool    (:app.db/pool th/*system*)
             data    {::th/type :request-email-change
-                     :profile-id (:id profile)
+                     ::rpc/profile-id (:id profile)
                      :email "user1@example.com"}
-            out     (th/mutation! data)]
+            out     (th/command! data)]
 
         ;; (th/print-result! out)
         (t/is (false? (:called? @mock)))
@@ -467,7 +467,7 @@
 
       ;; with invalid email
       (let [data (assoc data :email "foo@bar.com")
-            out  (th/mutation! data)]
+            out  (th/command! data)]
         (t/is (nil? (:result out)))
         (t/is (= 0 (:call-count @mock))))
 
@@ -512,10 +512,10 @@
 (t/deftest update-profile-password
   (let [profile (th/create-profile* 1)
         data  {::th/type :update-profile-password
-               :profile-id (:id profile)
+               ::rpc/profile-id (:id profile)
                :old-password "123123"
                :password "foobarfoobar"}
-        out   (th/mutation! data)]
+        out   (th/command! data)]
     (t/is (nil? (:error out)))
     (t/is (nil? (:result out)))
   ))
@@ -524,10 +524,10 @@
 (t/deftest update-profile-password-bad-old-password
   (let [profile (th/create-profile* 1)
         data  {::th/type :update-profile-password
-               :profile-id (:id profile)
+               ::rpc/profile-id (:id profile)
                :old-password "badpassword"
                :password "foobarfoobar"}
-        {:keys [result error] :as out} (th/mutation! data)]
+        {:keys [result error] :as out} (th/command! data)]
     (t/is (th/ex-info? error))
     (t/is (th/ex-of-type? error :validation))
     (t/is (th/ex-of-code? error :old-password-not-match))))
@@ -536,10 +536,10 @@
 (t/deftest update-profile-password-email-as-password
   (let [profile (th/create-profile* 1)
         data  {::th/type :update-profile-password
-               :profile-id (:id profile)
+               ::rpc/profile-id (:id profile)
                :old-password "123123"
                :password "profile1.test@nodomain.com"}
-        {:keys [result error] :as out} (th/mutation! data)]
+        {:keys [result error] :as out} (th/command! data)]
     (t/is (th/ex-info? error))
     (t/is (th/ex-of-type? error :validation))
     (t/is (th/ex-of-code? error :email-as-password))))
diff --git a/frontend/src/app/main/data/users.cljs b/frontend/src/app/main/data/users.cljs
index 906ddfd7a..5cf2932ab 100644
--- a/frontend/src/app/main/data/users.cljs
+++ b/frontend/src/app/main/data/users.cljs
@@ -124,7 +124,7 @@
   (ptk/reify ::fetch-profile
     ptk/WatchEvent
     (watch [_ _ _]
-      (->> (rp/query! :profile)
+      (->> (rp/cmd! :get-profile)
            (rx/map profile-fetched)))))
 
 ;; --- EVENT: INITIALIZE PROFILE
@@ -207,7 +207,7 @@
         ;; the returned profile is an NOT authenticated profile, we
         ;; proceed to logout and show an error message.
 
-        (->> (rp/command! :login-with-password (d/without-nils params))
+        (->> (rp/cmd! :login-with-password (d/without-nils params))
              (rx/merge-map (fn [data]
                              (rx/merge
                               (rx/of (fetch-profile))
@@ -293,7 +293,7 @@
    (ptk/reify ::logout
      ptk/WatchEvent
      (watch [_ _ _]
-       (->> (rp/command! :logout)
+       (->> (rp/cmd! :logout)
             (rx/delay-at-least 300)
             (rx/catch (constantly (rx/of 1)))
             (rx/map #(logged-out params)))))))
@@ -309,7 +309,7 @@
       (let [mdata      (meta data)
             on-success (:on-success mdata identity)
             on-error   (:on-error mdata rx/throw)]
-        (->> (rp/mutation :update-profile (dissoc data :props))
+        (->> (rp/cmd! :update-profile (dissoc data :props))
              (rx/catch on-error)
              (rx/mapcat
               (fn [_]
@@ -333,7 +333,7 @@
       (let [{:keys [on-error on-success]
              :or {on-error identity
                   on-success identity}} (meta data)]
-        (->> (rp/mutation :request-email-change data)
+        (->> (rp/cmd! :request-email-change data)
              (rx/tap on-success)
              (rx/catch on-error))))))
 
@@ -343,7 +343,7 @@
   (ptk/reify ::cancel-email-change
     ptk/WatchEvent
     (watch [_ _ _]
-      (->> (rp/mutation :cancel-email-change {})
+      (->> (rp/cmd! :cancel-email-change {})
            (rx/map (constantly (fetch-profile)))))))
 
 ;; --- Update Password (Form)
@@ -364,7 +364,7 @@
                   on-success identity}} (meta data)
             params {:old-password (:password-old data)
                     :password (:password-1 data)}]
-        (->> (rp/mutation :update-profile-password params)
+        (->> (rp/cmd! :update-profile-password params)
              (rx/tap on-success)
              (rx/catch (fn [err]
                          (on-error err)
@@ -382,7 +382,7 @@
     ;; the response value of update-profile-props RPC call
     ptk/WatchEvent
     (watch [_ _ _]
-      (->> (rp/mutation :update-profile-props {:props props})
+      (->> (rp/cmd! :update-profile-props {:props props})
            (rx/map (constantly (fetch-profile)))))))
 
 (defn mark-onboarding-as-viewed
@@ -394,7 +394,7 @@
        (let [version (or version (:main @cf/version))
              props   {:onboarding-viewed true
                       :release-notes-viewed version}]
-         (->> (rp/mutation :update-profile-props {:props props})
+         (->> (rp/cmd! :update-profile-props {:props props})
               (rx/map (constantly (fetch-profile)))))))))
 
 (defn mark-questions-as-answered
@@ -407,7 +407,7 @@
     ptk/WatchEvent
     (watch [_ _ _]
       (let [props {:onboarding-questions-answered true}]
-        (->> (rp/mutation :update-profile-props {:props props})
+        (->> (rp/cmd! :update-profile-props {:props props})
              (rx/map (constantly (fetch-profile))))))))
 
 
@@ -431,7 +431,7 @@
         (->> (rx/of file)
              (rx/map di/validate-file)
              (rx/map prepare)
-             (rx/mapcat #(rp/mutation :update-profile-photo %))
+             (rx/mapcat #(rp/cmd! :update-profile-photo %))
              (rx/do on-success)
              (rx/map (constantly (fetch-profile)))
              (rx/catch on-error))))))
@@ -460,7 +460,7 @@
       ptk/WatchEvent
       (watch [_ state _]
         (let [share-id (-> state :viewer-local :share-id)]
-          (->> (rp/command! :get-profiles-for-file-comments {:team-id team-id :share-id share-id})
+          (->> (rp/cmd! :get-profiles-for-file-comments {:team-id team-id :share-id share-id})
                (rx/map #(partial fetched %))))))))
 
 ;; --- EVENT: request-account-deletion
@@ -473,7 +473,7 @@
       (let [{:keys [on-error on-success]
              :or {on-error rx/throw
                   on-success identity}} (meta params)]
-        (->> (rp/mutation :delete-profile {})
+        (->> (rp/cmd! :delete-profile {})
              (rx/tap on-success)
              (rx/delay-at-least 300)
              (rx/catch (constantly (rx/of 1)))
@@ -495,7 +495,7 @@
              :or {on-error rx/throw
                   on-success identity}} (meta data)]
 
-        (->> (rp/command! :request-profile-recovery data)
+        (->> (rp/cmd! :request-profile-recovery data)
              (rx/tap on-success)
              (rx/catch on-error))))))
 
@@ -514,7 +514,7 @@
       (let [{:keys [on-error on-success]
              :or {on-error rx/throw
                   on-success identity}} (meta data)]
-        (->> (rp/command! :recover-profile data)
+        (->> (rp/cmd! :recover-profile data)
              (rx/tap on-success)
              (rx/catch on-error))))))
 
@@ -525,7 +525,7 @@
   (ptk/reify ::create-demo-profile
     ptk/WatchEvent
     (watch [_ _ _]
-      (->> (rp/command! :create-demo-profile {})
+      (->> (rp/cmd! :create-demo-profile {})
            (rx/map login)))))
 
 
diff --git a/frontend/src/app/main/data/workspace/persistence.cljs b/frontend/src/app/main/data/workspace/persistence.cljs
index e2f79a913..61942fd5d 100644
--- a/frontend/src/app/main/data/workspace/persistence.cljs
+++ b/frontend/src/app/main/data/workspace/persistence.cljs
@@ -214,7 +214,7 @@
                      :features features}]
 
         (when (:id params)
-          (->> (rp/mutation :update-file params)
+          (->> (rp/cmd! :update-file params)
                (rx/ignore)))))))
 
 (defn update-persistence-status
diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs
index b5b67fefc..be89b473e 100644
--- a/frontend/src/app/main/repo.cljs
+++ b/frontend/src/app/main/repo.cljs
@@ -23,6 +23,7 @@
 (derive :get-team-stats ::query)
 (derive :get-team-invitations ::query)
 (derive :get-team-shared-files ::query)
+(derive :get-profile ::query)
 
 (defn handle-response
   [{:keys [status body] :as response}]
@@ -191,3 +192,12 @@
                     :body (http/form-data params)})
        (rx/map http/conditional-decode-transit)
        (rx/mapcat handle-response)))
+
+(defmethod command ::multipart-upload
+  [id params]
+  (->> (http/send! {:method :post
+                    :uri  (u/join @cf/public-uri "api/rpc/command/" (name id))
+                    :credentials "include"
+                    :body (http/form-data params)})
+       (rx/map http/conditional-decode-transit)
+       (rx/mapcat handle-response)))
diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs
index d6ffde3fe..c704cadfc 100644
--- a/frontend/src/app/main/ui/routes.cljs
+++ b/frontend/src/app/main/ui/routes.cljs
@@ -99,7 +99,7 @@
     ;; We just recheck with an additional profile request; this avoids
     ;; some race conditions that causes unexpected redirects on
     ;; invitations workflows (and probably other cases).
-    (->> (rp/query! :profile)
+    (->> (rp/command! :get-profile)
          (rx/subs (fn [{:keys [id] :as profile}]
                     (if (= id uuid/zero)
                       (st/emit! (rt/nav :auth-login))