From 4fc3f316e02362052dcca4df3bdbaff54c504eb6 Mon Sep 17 00:00:00 2001
From: Pablo Alba <pablo.alba@kaleidos.net>
Date: Tue, 27 Dec 2022 07:03:12 +0100
Subject: [PATCH 01/19] :bug: Add function to reparent orphan shapes

---
 frontend/src/app/main/data/workspace.cljs     | 21 +++++++++++++++++--
 .../main/data/workspace/state_helpers.cljs    | 15 +++++++++++--
 frontend/src/debug.cljs                       |  3 +++
 3 files changed, 35 insertions(+), 4 deletions(-)

diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs
index 206deef13..6ce0e0e0b 100644
--- a/frontend/src/app/main/data/workspace.cljs
+++ b/frontend/src/app/main/data/workspace.cljs
@@ -696,7 +696,7 @@
         (pcb/resize-parents parents))))
 
 (defn relocate-shapes
-  [ids parent-id to-index]
+  [ids parent-id to-index & [ignore-parents?]]
   (us/verify (s/coll-of ::us/uuid) ids)
   (us/verify ::us/uuid parent-id)
   (us/verify number? to-index)
@@ -712,7 +712,9 @@
 
             ;; If we try to move a parent into a child we remove it
             ids      (filter #(not (cph/is-parent? objects parent-id %)) ids)
-            parents  (into #{parent-id} (map #(cph/get-parent-id objects %)) ids)
+            parents  (if ignore-parents?
+                       #{parent-id}
+                       (into #{parent-id} (map #(cph/get-parent-id objects %)) ids))
 
             groups-to-delete
             (loop [current-id  (first parents)
@@ -1832,6 +1834,21 @@
         (rx/empty)))))
 
 
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; Orphan Shapes
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+
+(defn fix-orphan-shapes
+  []
+  (ptk/reify ::fix-orphan-shapes
+    ptk/WatchEvent
+    (watch [_ state _]
+      (let [orphans (set (into [] (keys (wsh/find-orphan-shapes state))))]
+        (rx/of (relocate-shapes orphans uuid/zero 0 true))))))
+
+
+
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;; Inspect
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/frontend/src/app/main/data/workspace/state_helpers.cljs b/frontend/src/app/main/data/workspace/state_helpers.cljs
index b6596511b..dd01688d6 100644
--- a/frontend/src/app/main/data/workspace/state_helpers.cljs
+++ b/frontend/src/app/main/data/workspace/state_helpers.cljs
@@ -11,7 +11,8 @@
    [app.common.geom.point :as gpt]
    [app.common.geom.shapes :as gsh]
    [app.common.pages.helpers :as cph]
-   [app.common.path.commands :as upc]))
+   [app.common.path.commands :as upc]
+   [app.common.uuid :as uuid]))
 
 (defn lookup-page
   ([state]
@@ -146,4 +147,14 @@
   (let [{:keys [x y width height]} (get-in state [:workspace-local :vbox])]
     (gpt/point (+ x (/ width 2)) (+ y (/ height 2)))))
 
-
+(defn find-orphan-shapes
+  ([state]
+   (find-orphan-shapes state (:current-page-id state)))
+  ([state page-id]
+   (let [objects  (lookup-page-objects state page-id)
+         objects (filter (fn [item]
+                           (and
+                            (not= (key item) uuid/zero)
+                            (not (contains? objects (:parent-id (val item))))))
+                         objects)]
+     objects)))
diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs
index cfb8b1c40..f2d2e729a 100644
--- a/frontend/src/debug.cljs
+++ b/frontend/src/debug.cljs
@@ -346,3 +346,6 @@
   [read-only?]
   (st/emit! (dw/set-workspace-read-only read-only?)))
 
+(defn ^:export fix-orphan-shapes
+  []
+  (st/emit! (dw/fix-orphan-shapes)))

From ede07e4f448b4090d2326992598e6e0dddbd283b Mon Sep 17 00:00:00 2001
From: Pablo Alba <pablo.alba@kaleidos.net>
Date: Wed, 28 Dec 2022 15:56:16 +0100
Subject: [PATCH 02/19] :bug: Fix incorrect color in properties of multiple
 bool shapes

---
 CHANGES.md                                      |  1 +
 common/src/app/common/pages/common.cljc         |  1 +
 .../sidebar/options/shapes/multiple.cljs        | 17 ++++++++++++++++-
 3 files changed, 18 insertions(+), 1 deletion(-)

diff --git a/CHANGES.md b/CHANGES.md
index 774ad0a2b..62ca75f71 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -34,6 +34,7 @@
 - Fix selection stroke missing in properties of multiple texts [Taiga #4048](https://tree.taiga.io/project/penpot/issue/4048)
 - Fix missing create component menu for frames [Github #2670](https://github.com/penpot/penpot/issues/2670)
 - Fix "currentColor" is not converted when importing SVG [Github 2276](https://github.com/penpot/penpot/issues/2276)
+- Fix incorrect color in properties of multiple bool shapes [Taiga #4355](https://tree.taiga.io/project/penpot/issue/4355)
 
 ### :arrow_up: Deps updates
 
diff --git a/common/src/app/common/pages/common.cljc b/common/src/app/common/pages/common.cljc
index dbfe83cba..f12ec2d35 100644
--- a/common/src/app/common/pages/common.cljc
+++ b/common/src/app/common/pages/common.cljc
@@ -537,6 +537,7 @@
            :blocked
            :hidden
 
+           :fills
            :fill-color
            :fill-opacity
            :fill-color-ref-id
diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs
index fe9365274..5579183e2 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs
@@ -247,6 +247,18 @@
     (= (:type shape) :path)
     (dissoc :content)))
 
+(defn- is-bool-descendant?
+  [shape all-shapes selected-shape-ids]
+  (let [parent-id (:parent-id shape)
+        parent (->> all-shapes
+                    (filter #(= (:id %) parent-id))
+                    first)]
+    (cond
+      (nil? shape) false                                                   ;; failsafe
+      (some #{(:id shape)} selected-shape-ids) false                       ;; if it is one of the selected shapes, it is considerer not a bool descendant
+      (= :bool (:type parent)) true                                        ;; if its parent is of type bool, it is a bool descendant
+      :else (is-bool-descendant? parent all-shapes selected-shape-ids))))  ;; else, check its parent
+
 (mf/defc options
   {::mf/wrap [#(mf/memo' % (mf/check-props ["shapes" "shapes-with-children" "page-id" "file-id"]))]
    ::mf/wrap-props false}
@@ -254,6 +266,10 @@
   (let [shapes (unchecked-get props "shapes")
         shapes-with-children (unchecked-get props "shapes-with-children")
 
+        ;; remove children from bool shapes
+        shape-ids (map :id shapes)
+        shapes-with-children (filter #(not (is-bool-descendant? % shapes-with-children shape-ids)) shapes-with-children)
+
         workspace-modifiers (mf/deref refs/workspace-modifiers)
         shapes (map #(gsh/transform-shape % (get-in workspace-modifiers [(:id %) :modifiers])) shapes)
 
@@ -279,7 +295,6 @@
 
         [measure-ids    measure-values]    (get-attrs shapes objects :measure)
 
-
         [layer-ids            layer-values
          constraint-ids       constraint-values
          fill-ids             fill-values

From ed4a5f6c6007e7153f53ed588489424d8e156c30 Mon Sep 17 00:00:00 2001
From: Alejandro Alonso <alejandroalonsofernandez@gmail.com>
Date: Fri, 30 Dec 2022 11:30:03 +0100
Subject: [PATCH 03/19] :bug: Fix ignore booleans on ctrl + click selection

---
 frontend/src/app/main/ui/workspace/viewport/hooks.cljs | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs
index 1c0868f36..fb20a66fb 100644
--- a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs
@@ -126,7 +126,6 @@
          (mf/deps page-id)
          (fn [point]
            (let [zoom (mf/ref-val zoom-ref)
-                 mod? (mf/ref-val mod-ref)
                  rect (gsh/center->rect point (/ 5 zoom) (/ 5 zoom))]
              (if (mf/ref-val hover-disabled-ref)
                (rx/of nil)
@@ -135,7 +134,7 @@
                       :page-id page-id
                       :rect rect
                       :include-frames? true
-                      :clip-children? (not mod?)})
+                      :clip-children? true})
                     ;; When the ask-buffered is canceled returns null. We filter them
                     ;; to improve the behavior
                     (rx/filter some?))))))

From d1dbc3850dcd508061d7b4a50d17ce6eeb2f581f Mon Sep 17 00:00:00 2001
From: Pablo Alba <pablo.alba@kaleidos.net>
Date: Fri, 30 Dec 2022 11:11:12 +0100
Subject: [PATCH 04/19] :bug: Show color name on inspect

---
 .../styles/main/partials/color-bullet.scss    |  1 +
 .../app/main/ui/components/color_bullet.cljs  |  4 +--
 .../ui/viewer/inspect/attributes/common.cljs  | 33 ++++++++++++-------
 3 files changed, 25 insertions(+), 13 deletions(-)

diff --git a/frontend/resources/styles/main/partials/color-bullet.scss b/frontend/resources/styles/main/partials/color-bullet.scss
index 7b1c98800..6a86edd37 100644
--- a/frontend/resources/styles/main/partials/color-bullet.scss
+++ b/frontend/resources/styles/main/partials/color-bullet.scss
@@ -184,6 +184,7 @@ ul.palette-menu .color-bullet {
 
 .color-bullet.is-not-library-color {
   border-radius: $br-small;
+  overflow: hidden;
 
   & .color-bullet-wrapper {
     clip-path: none;
diff --git a/frontend/src/app/main/ui/components/color_bullet.cljs b/frontend/src/app/main/ui/components/color_bullet.cljs
index 4342e25dc..0260aa7ee 100644
--- a/frontend/src/app/main/ui/components/color_bullet.cljs
+++ b/frontend/src/app/main/ui/components/color_bullet.cljs
@@ -31,12 +31,12 @@
 
       ;; No multiple selection
       (let [color (if (string? color) {:color color :opacity 1} color)]
-        [:div.color-bullet.tooltip.tooltip-right
+        [:div.color-bullet
          {:class (dom/classnames :is-library-color (some? (:id color))
                                  :is-not-library-color (nil? (:id color))
                                  :is-gradient (some? (:gradient color)))
           :on-click on-click
-          :title (or (:name color) (:color color) (gradient-type->string (:type (:gradient color))))}
+          :title (or (:color-library-name color) (:name color) (:color color) (gradient-type->string (:type (:gradient color))))}
          (if  (:gradient color)
            [:div.color-bullet-wrapper {:style {:background (uc/color->background color)}}]
            [:div.color-bullet-wrapper
diff --git a/frontend/src/app/main/ui/viewer/inspect/attributes/common.cljs b/frontend/src/app/main/ui/viewer/inspect/attributes/common.cljs
index 0dd81d1d0..b0959c464 100644
--- a/frontend/src/app/main/ui/viewer/inspect/attributes/common.cljs
+++ b/frontend/src/app/main/ui/viewer/inspect/attributes/common.cljs
@@ -6,6 +6,7 @@
 
 (ns app.main.ui.viewer.inspect.attributes.common
   (:require
+   [app.main.refs :as refs]
    [app.main.store :as st]
    [app.main.ui.components.color-bullet :refer [color-bullet color-name]]
    [app.main.ui.components.copy-button :refer [copy-button]]
@@ -20,21 +21,31 @@
 (def file-colors-ref
   (l/derived (l/in [:viewer :file :data :colors]) st/state))
 
-(defn make-colors-library-ref [file-id]
+(defn make-colors-library-ref [libraries-place file-id]
   (let [get-library
         (fn [state]
-          (get-in state [:viewer-libraries file-id :data :colors]))]
-    #(l/derived get-library st/state)))
+          (get-in state [libraries-place file-id :data :colors]))]
+    (l/derived get-library st/state)))
+
+(defn- get-colors-library [color]
+  (let [colors-library-v  (-> (mf/use-memo
+                               (mf/deps (:file-id color))
+                               #(make-colors-library-ref :viewer-libraries (:file-id color)))
+                              mf/deref)
+        colors-library-ws (-> (mf/use-memo
+                               (mf/deps (:file-id color))
+                               #(make-colors-library-ref :workspace-libraries (:file-id color)))
+                              mf/deref)]
+    (or colors-library-v colors-library-ws)))
+
+(defn- get-file-colors []
+  (or (mf/deref file-colors-ref) (mf/deref refs/workspace-file-colors)))
 
 (mf/defc color-row [{:keys [color format copy-data on-change-format]}]
-  (let [colors-library-ref (mf/use-memo
-                            (mf/deps (:file-id color))
-                            (make-colors-library-ref (:file-id color)))
-        colors-library (mf/deref colors-library-ref)
-
-        file-colors (mf/deref file-colors-ref)
-
-        color-library-name (get-in (or colors-library file-colors) [(:id color) :name])]
+  (let [colors-library (get-colors-library color)
+        file-colors (get-file-colors)
+        color-library-name (get-in (or colors-library file-colors) [(:id color) :name])
+        color (assoc color :color-library-name color-library-name)]
     [:div.attributes-color-row
      (when color-library-name
        [:div.attributes-color-id

From 42b69df671cb033a239f0e9602109cd316dc33b8 Mon Sep 17 00:00:00 2001
From: Pablo Alba <pablo.alba@kaleidos.net>
Date: Fri, 30 Dec 2022 13:34:47 +0100
Subject: [PATCH 05/19] :bug: Fix internal error at setting text style

---
 .../app/main/ui/workspace/sidebar/options/menus/typography.cljs  | 1 +
 1 file changed, 1 insertion(+)

diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs
index a6fae4c3b..e00b2ef41 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs
@@ -462,6 +462,7 @@
         on-change-ref        (mf/use-ref nil)
         workspace-read-only? (mf/use-ctx ctx/workspace-read-only?)
         editable?            (and local? (not workspace-read-only?))
+        open?                (if (nil? open?) (mf/use-state editing?) open?)
 
         on-name-blur
         (mf/use-callback

From 941aa6ad5d2bef660e422f17b61a3f4a397e1a42 Mon Sep 17 00:00:00 2001
From: Andrey Antukh <niwi@niwi.nz>
Date: Wed, 28 Dec 2022 09:22:42 +0100
Subject: [PATCH 06/19] :fire: Remove unused configuration attrs

---
 backend/src/app/config.clj | 10 ----------
 1 file changed, 10 deletions(-)

diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj
index 1cb1b2aca..c4cc0ee5a 100644
--- a/backend/src/app/config.clj
+++ b/backend/src/app/config.clj
@@ -192,15 +192,10 @@
 (s/def ::srepl-host ::us/string)
 (s/def ::srepl-port ::us/integer)
 (s/def ::assets-storage-backend ::us/keyword)
-(s/def ::fdata-storage-backend ::us/keyword)
 (s/def ::storage-assets-fs-directory ::us/string)
 (s/def ::storage-assets-s3-bucket ::us/string)
 (s/def ::storage-assets-s3-region ::us/keyword)
 (s/def ::storage-assets-s3-endpoint ::us/string)
-(s/def ::storage-fdata-s3-bucket ::us/string)
-(s/def ::storage-fdata-s3-region ::us/keyword)
-(s/def ::storage-fdata-s3-prefix ::us/string)
-(s/def ::storage-fdata-s3-endpoint ::us/string)
 (s/def ::telemetry-uri ::us/string)
 (s/def ::telemetry-with-taiga ::us/boolean)
 (s/def ::tenant ::us/string)
@@ -306,11 +301,6 @@
                    ::storage-assets-s3-bucket
                    ::storage-assets-s3-region
                    ::storage-assets-s3-endpoint
-                   ::fdata-storage-backend
-                   ::storage-fdata-s3-bucket
-                   ::storage-fdata-s3-region
-                   ::storage-fdata-s3-prefix
-                   ::storage-fdata-s3-endpoint
                    ::telemetry-enabled
                    ::telemetry-uri
                    ::telemetry-referer

From 3b61a7dd91219cf87b2361ac57d0739090a9fa5d Mon Sep 17 00:00:00 2001
From: Andrey Antukh <niwi@niwi.nz>
Date: Sat, 31 Dec 2022 10:48:27 +0100
Subject: [PATCH 07/19] :bug: Fix incorrect arguments to process-changes

---
 .../app/main/data/workspace/persistence.cljs  | 35 ++++++++++---------
 1 file changed, 19 insertions(+), 16 deletions(-)

diff --git a/frontend/src/app/main/data/workspace/persistence.cljs b/frontend/src/app/main/data/workspace/persistence.cljs
index 29df1fd43..2d8d1d8e9 100644
--- a/frontend/src/app/main/data/workspace/persistence.cljs
+++ b/frontend/src/app/main/data/workspace/persistence.cljs
@@ -216,29 +216,32 @@
   [file-id {:keys [revn changes] :as params}]
   (us/verify! ::us/uuid file-id)
   (us/verify! ::shapes-changes-persisted params)
-  (ptk/reify ::changes-persisted
+  (ptk/reify ::shapes-changes-persisted
     ptk/UpdateEvent
     (update [_ state]
       ;; NOTE: we don't set the file features context here because
       ;; there are no useful context for code that need to be executed
       ;; on the frontend side
-      (let [changes (group-by :page-id changes)]
-        (if (= file-id (:current-file-id state))
-          (-> state
-              (update-in [:workspace-file :revn] max revn)
-              (update :workspace-data (fn [file]
-                                        (loop [fdata file
-                                               entries (seq changes)]
-                                          (if-let [[page-id changes] (first entries)]
-                                            (recur (-> fdata
-                                                       (cp/process-changes changes)
-                                                       (ctst/update-object-indices page-id))
-                                                   (rest entries))
-                                            fdata)))))
+
+      (if-let [current-file-id (:current-file-id state)]
+        (if (= file-id current-file-id)
+          (let [changes (group-by :page-id changes)]
+            (-> state
+                (update-in [:workspace-file :revn] max revn)
+                (update :workspace-data (fn [file]
+                                          (loop [fdata file
+                                                 entries (seq changes)]
+                                            (if-let [[page-id changes] (first entries)]
+                                              (recur (-> fdata
+                                                         (cp/process-changes changes)
+                                                         (ctst/update-object-indices page-id))
+                                                     (rest entries))
+                                              fdata))))))
           (-> state
               (update-in [:workspace-libraries file-id :revn] max revn)
-              (update-in [:workspace-libraries file-id :data]
-                         cp/process-changes changes)))))))
+              (update-in [:workspace-libraries file-id :data] cp/process-changes changes)))
+
+        state))))
 
 
 

From 7a8b0e710b0b5d16abed2170a5791e3d1b862ae3 Mon Sep 17 00:00:00 2001
From: Andrey Antukh <niwi@niwi.nz>
Date: Sat, 31 Dec 2022 10:49:04 +0100
Subject: [PATCH 08/19] :sparkles: Improve trace reporting on unhandled
 exception

---
 frontend/src/app/main/errors.cljs | 11 +++++++++--
 1 file changed, 9 insertions(+), 2 deletions(-)

diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs
index fe9eb8744..d6d732923 100644
--- a/frontend/src/app/main/errors.cljs
+++ b/frontend/src/app/main/errors.cljs
@@ -30,7 +30,14 @@
   [error]
   (cond
     (instance? ExceptionInfo error)
-    (-> error ex-data ptk/handle-error)
+    (let [data (ex-data error)]
+      (if (contains? data :type)
+        (ptk/handle-error data)
+        (let [hint (str/ffmt "Unexpected error: '%'" (ex-message error))]
+          (ts/schedule #(st/emit! (rt/assign-exception error)))
+          (js/console.group hint)
+          (js/console.log (.-stack error))
+          (js/console.groupEnd hint))))
 
     (map? error)
     (ptk/handle-error error)
@@ -49,7 +56,7 @@
 
 (defmethod ptk/handle-error :default
   [error]
-  (let [hint (str/concat "Unexpected error: " (:hint error))]
+  (let [hint (str/ffmt "Unhandled error: '%'" (:hint error "[no hint]"))]
     (ts/schedule #(st/emit! (rt/assign-exception error)))
     (js/console.group hint)
     (ex/ignoring (js/console.error (pr-str error)))

From d68be0869b502f453fd2edaabcea07a3eabd658b Mon Sep 17 00:00:00 2001
From: Andrey Antukh <niwi@niwi.nz>
Date: Sat, 31 Dec 2022 10:49:29 +0100
Subject: [PATCH 09/19] :sparkles: Improve error report on point constructor

---
 common/src/app/common/geom/point.cljc | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/common/src/app/common/geom/point.cljc b/common/src/app/common/geom/point.cljc
index e15f0fff0..bfc55dc6d 100644
--- a/common/src/app/common/geom/point.cljc
+++ b/common/src/app/common/geom/point.cljc
@@ -13,6 +13,7 @@
       :clj [clojure.core :as c])
    [app.common.data :as d]
    [app.common.data.macros :as dm]
+   [app.common.exceptions :as ex]
    [app.common.math :as mth]
    [app.common.spec :as us]
    [clojure.spec.alpha :as s]
@@ -62,7 +63,7 @@
      (map->Point v)
 
      :else
-     (throw (ex-info "Invalid arguments" {:v v}))))
+     (ex/raise :hint "invalid arguments (on pointer constructor)" :value v)))
   ([x y]
    (Point. x y)))
 

From 73a3e0c0ae7444feef28b1e4d56b42186989e2f8 Mon Sep 17 00:00:00 2001
From: Andrey Antukh <niwi@niwi.nz>
Date: Tue, 27 Dec 2022 20:04:41 +0100
Subject: [PATCH 10/19] :tada: Add usage quotes

---
 backend/src/app/config.clj                    |  22 +-
 backend/src/app/db.clj                        |   1 +
 backend/src/app/emails.clj                    |  18 +-
 backend/src/app/migrations.clj                |   5 +-
 .../migrations/sql/0098-add-quotes-table.sql  |  82 +++++
 backend/src/app/rpc/commands/files/create.clj |   9 +-
 backend/src/app/rpc/commands/teams.clj        |  27 ++
 backend/src/app/rpc/commands/verify_token.clj |   6 +
 backend/src/app/rpc/mutations/fonts.clj       |   4 +
 backend/src/app/rpc/mutations/projects.clj    |   5 +
 backend/src/app/rpc/quotes.clj                | 327 +++++++++++++++++
 backend/test/backend_tests/helpers.clj        |   3 +-
 backend/test/backend_tests/rpc_font_test.clj  |  68 ++--
 .../test/backend_tests/rpc_quotes_test.clj    | 344 ++++++++++++++++++
 common/src/app/common/spec.cljc               |  15 +-
 common/src/app/common/types/shape_tree.cljc   |  13 +-
 .../main/data/workspace/notifications.cljs    |   1 -
 frontend/src/app/main/errors.cljs             |  16 +-
 frontend/translations/en.po                   |   4 +
 frontend/translations/es.po                   |  12 +
 20 files changed, 914 insertions(+), 68 deletions(-)
 create mode 100644 backend/src/app/migrations/sql/0098-add-quotes-table.sql
 create mode 100644 backend/src/app/rpc/quotes.clj
 create mode 100644 backend/test/backend_tests/rpc_quotes_test.clj

diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj
index c4cc0ee5a..1fe8ddd2b 100644
--- a/backend/src/app/config.clj
+++ b/backend/src/app/config.clj
@@ -102,7 +102,7 @@
 (s/def ::audit-log-archive-uri ::us/string)
 (s/def ::audit-log-http-handler-concurrency ::us/integer)
 
-(s/def ::admins ::us/set-of-strings)
+(s/def ::admins ::us/set-of-valid-emails)
 (s/def ::file-change-snapshot-every ::us/integer)
 (s/def ::file-change-snapshot-timeout ::dt/duration)
 
@@ -130,6 +130,14 @@
 (s/def ::database-min-pool-size ::us/integer)
 (s/def ::database-max-pool-size ::us/integer)
 
+(s/def ::quotes-teams-per-profile ::us/integer)
+(s/def ::quotes-projects-per-team ::us/integer)
+(s/def ::quotes-invitations-per-team ::us/integer)
+(s/def ::quotes-profiles-per-team ::us/integer)
+(s/def ::quotes-files-per-project ::us/integer)
+(s/def ::quotes-files-per-team ::us/integer)
+(s/def ::quotes-font-variants-per-team ::us/integer)
+
 (s/def ::default-blob-version ::us/integer)
 (s/def ::error-report-webhook ::us/string)
 (s/def ::user-feedback-destination ::us/string)
@@ -272,6 +280,15 @@
                    ::profile-complaint-max-age
                    ::profile-complaint-threshold
                    ::public-uri
+
+                   ::quotes-teams-per-profile
+                   ::quotes-projects-per-team
+                   ::quotes-invitations-per-team
+                   ::quotes-profiles-per-team
+                   ::quotes-files-per-project
+                   ::quotes-files-per-team
+                   ::quotes-font-variants-per-team
+
                    ::redis-uri
                    ::registration-domain-whitelist
                    ::rpc-rlimit-config
@@ -311,7 +328,8 @@
   [:enable-backend-api-doc
    :enable-backend-worker
    :enable-secure-session-cookies
-   :enable-email-verification])
+   :enable-email-verification
+   :enable-quotes])
 
 (defn- parse-flags
   [config]
diff --git a/backend/src/app/db.clj b/backend/src/app/db.clj
index 6e4d12061..045ee46a9 100644
--- a/backend/src/app/db.clj
+++ b/backend/src/app/db.clj
@@ -167,6 +167,7 @@
   (instance? javax.sql.DataSource v))
 
 (s/def ::pool pool?)
+(s/def ::conn-or-pool some?)
 
 (defn closed?
   [pool]
diff --git a/backend/src/app/emails.clj b/backend/src/app/emails.clj
index d072845a5..8a69f11d6 100644
--- a/backend/src/app/emails.clj
+++ b/backend/src/app/emails.clj
@@ -257,15 +257,17 @@
   "Schedule an already defined email to be sent using asynchronously
   using worker task."
   [{:keys [::conn ::factory] :as context}]
-  (us/verify fn? factory)
   (us/verify some? conn)
-  (let [email (factory context)]
-    (wrk/submit! (assoc email
-                        ::wrk/task :sendmail
-                        ::wrk/delay 0
-                        ::wrk/max-retries 4
-                        ::wrk/priority 200
-                        ::wrk/conn conn))))
+  (let [email (if factory
+                (factory context)
+                (dissoc context ::conn))]
+    (wrk/submit! (merge
+                  {::wrk/task :sendmail
+                   ::wrk/delay 0
+                   ::wrk/max-retries 4
+                   ::wrk/priority 200
+                   ::wrk/conn conn}
+                  email))))
 
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;; SENDMAIL FN / TASK HANDLER
diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj
index d8b13bcbb..b50bf97c6 100644
--- a/backend/src/app/migrations.clj
+++ b/backend/src/app/migrations.clj
@@ -302,7 +302,10 @@
    {:name "0097-mod-profile-table"
     :fn (mg/resource "app/migrations/sql/0097-mod-profile-table.sql")}
 
-   ])
+   {:name "0098-add-quotes-table"
+    :fn (mg/resource "app/migrations/sql/0098-add-quotes-table.sql")}
+
+  ])
 
 
 (defmethod ig/init-key ::migrations [_ _] migrations)
diff --git a/backend/src/app/migrations/sql/0098-add-quotes-table.sql b/backend/src/app/migrations/sql/0098-add-quotes-table.sql
new file mode 100644
index 000000000..3041f7846
--- /dev/null
+++ b/backend/src/app/migrations/sql/0098-add-quotes-table.sql
@@ -0,0 +1,82 @@
+CREATE TABLE usage_quote (
+  id uuid NOT NULL DEFAULT uuid_generate_v4() PRIMARY KEY,
+  target text NOT NULL,
+  quote bigint NOT NULL,
+
+  profile_id uuid NULL REFERENCES profile(id) ON DELETE CASCADE DEFERRABLE,
+  project_id uuid NULL REFERENCES project(id) ON DELETE CASCADE DEFERRABLE,
+  team_id uuid NULL REFERENCES team(id) ON DELETE CASCADE DEFERRABLE,
+  file_id uuid NULL REFERENCES file(id) ON DELETE CASCADE DEFERRABLE
+);
+
+ALTER TABLE usage_quote
+  ALTER COLUMN target SET STORAGE external;
+
+CREATE INDEX usage_quote__profile_id__idx ON usage_quote(profile_id, target);
+CREATE INDEX usage_quote__project_id__idx ON usage_quote(project_id, target);
+CREATE INDEX usage_quote__team_id__idx ON usage_quote(team_id, target);
+
+-- DROP TABLE IF EXISTS usage_quote_test;
+-- CREATE TABLE usage_quote_test (
+--   id bigserial NOT NULL PRIMARY KEY,
+--   target text NOT NULL,
+--   quote bigint NOT NULL,
+
+--   profile_id bigint NULL,
+--   team_id bigint NULL,
+--   project_id bigint NULL,
+--   file_id bigint NULL
+-- );
+
+-- ALTER TABLE usage_quote_test
+--   ALTER COLUMN target SET STORAGE external;
+
+-- CREATE INDEX usage_quote_test__profile_id__idx ON usage_quote_test(profile_id, target);
+-- CREATE INDEX usage_quote_test__project_id__idx ON usage_quote_test(project_id, target);
+-- CREATE INDEX usage_quote_test__team_id__idx ON usage_quote_test(team_id, target);
+-- -- CREATE INDEX usage_quote_test__target__idx ON usage_quote_test(target);
+
+-- DELETE FROM usage_quote_test;
+
+-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
+-- SELECT 'files-per-project', 50*RANDOM(), 2000*RANDOM(), null, null
+--   FROM generate_series(1, 5000);
+
+-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
+-- SELECT 'files-per-project', 200*RANDOM(), 300*RANDOM(), 300*RANDOM(), null
+--   FROM generate_series(1, 1000);
+
+-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
+-- SELECT 'files-per-project', 100*RANDOM(), 300*RANDOM(), null, 300*RANDOM()
+--   FROM generate_series(1, 1000);
+
+-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
+-- SELECT 'files-per-project', 100*RANDOM(), 300*RANDOM(), 300*RANDOM(), 300*RANDOM()
+--   FROM generate_series(1, 1000);
+
+-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
+-- SELECT 'files-per-project', 30*RANDOM(), null, 2000*RANDOM(), null
+--   FROM generate_series(1, 5000);
+
+-- INSERT INTO usage_quote_test (target, quote, profile_id, team_id, project_id)
+-- SELECT 'files-per-project', 10*RANDOM(), null, null, 2000*RANDOM()
+--   FROM generate_series(1, 5000);
+
+-- VACUUM ANALYZE usage_quote_test;
+
+-- select * from usage_quote_test
+--  where target = 'files-per-project'
+--    and profile_id = 1
+--    and team_id is null
+--    and project_id is null;
+
+-- select * from usage_quote_test
+--  where target = 'files-per-project'
+--    and ((team_id = 1 and (profile_id = 1 or profile_id is null)) or
+--         (profile_id = 1 and team_id is null and project_id is null));
+
+-- select * from usage_quote_test
+--  where target = 'files-per-project'
+--    and ((project_id = 1 and (profile_id = 1 or profile_id is null)) or
+--         (team_id = 1 and (profile_id = 1 or profile_id is null)) or
+--         (profile_id = 1 and team_id is null and project_id is null));
diff --git a/backend/src/app/rpc/commands/files/create.clj b/backend/src/app/rpc/commands/files/create.clj
index 4a2b4d641..2d4a7a808 100644
--- a/backend/src/app/rpc/commands/files/create.clj
+++ b/backend/src/app/rpc/commands/files/create.clj
@@ -18,6 +18,7 @@
    [app.rpc.doc :as-alias doc]
    [app.rpc.permissions :as perms]
    [app.rpc.queries.projects :as proj]
+   [app.rpc.quotes :as quotes]
    [app.util.blob :as blob]
    [app.util.objects-map :as omap]
    [app.util.pointer-map :as pmap]
@@ -84,6 +85,12 @@
     (proj/check-edition-permissions! conn profile-id project-id)
     (let [team-id (files/get-team-id conn project-id)
           params  (assoc params :profile-id profile-id)]
+
+      (run! (partial quotes/check-quote! conn)
+            (list {::quotes/id ::quotes/files-per-project
+                   ::quotes/team-id team-id
+                   ::quotes/profile-id profile-id
+                   ::quotes/project-id project-id}))
+
       (-> (create-file conn params)
           (vary-meta assoc ::audit/props {:team-id team-id})))))
-
diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj
index f09ce9ede..f040a034f 100644
--- a/backend/src/app/rpc/commands/teams.clj
+++ b/backend/src/app/rpc/commands/teams.clj
@@ -23,6 +23,7 @@
    [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]
    [app.util.services :as sv]
@@ -297,6 +298,9 @@
   {::doc/added "1.17"}
   [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
   (db/with-atomic [conn pool]
+    (quotes/check-quote! conn {::quotes/id ::quotes/teams-per-profile
+                               ::quotes/profile-id profile-id})
+
     (create-team conn (assoc params :profile-id profile-id))))
 
 (defn create-team
@@ -739,6 +743,17 @@
           team     (db/get-by-id conn :team team-id)
           emails   (cond-> (or emails #{}) (string? email) (conj email))]
 
+
+      (run! (partial quotes/check-quote! conn)
+            (list {::quotes/id ::quotes/invitations-per-team
+                   ::quotes/profile-id profile-id
+                   ::quotes/team-id (:id team)
+                   ::quotes/incr (count emails)}
+                  {::quotes/id ::quotes/profiles-per-team
+                   ::quotes/profile-id profile-id
+                   ::quotes/team-id (:id team)
+                   ::quotes/incr (count emails)}))
+
       (when-not (:is-admin perms)
         (ex/raise :type :validation
                   :code :insufficient-permissions))
@@ -785,6 +800,18 @@
                    :role role}))
            (run! (partial create-invitation cfg)))
 
+      (run! (partial quotes/check-quote! conn)
+            (list {::quotes/id ::quotes/teams-per-profile
+                   ::quotes/profile-id profile-id}
+                  {::quotes/id ::quotes/invitations-per-team
+                   ::quotes/profile-id profile-id
+                   ::quotes/team-id (:id team)
+                   ::quotes/incr (count emails)}
+                  {::quotes/id ::quotes/profiles-per-team
+                   ::quotes/profile-id profile-id
+                   ::quotes/team-id (:id team)
+                   ::quotes/incr (count emails)}))
+
       (-> team
           (vary-meta assoc ::audit/props {:invitations (count emails)})
           (rph/with-defer
diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj
index b9b1673d2..6eb455f22 100644
--- a/backend/src/app/rpc/commands/verify_token.clj
+++ b/backend/src/app/rpc/commands/verify_token.clj
@@ -16,6 +16,7 @@
    [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]
    [app.util.services :as sv]
@@ -96,6 +97,11 @@
       (ex/raise :type :restriction
                 :code :profile-blocked))
 
+    (quotes/check-quote! conn
+                         {::quotes/id ::quotes/profiles-per-team
+                          ::quotes/profile-id (:id member)
+                          ::quotes/team-id team-id})
+
     ;; Insert the invited member to the team
     (db/insert! conn :team-profile-rel params {:on-conflict-do-nothing true})
 
diff --git a/backend/src/app/rpc/mutations/fonts.clj b/backend/src/app/rpc/mutations/fonts.clj
index 1f00de84c..e354074f8 100644
--- a/backend/src/app/rpc/mutations/fonts.clj
+++ b/backend/src/app/rpc/mutations/fonts.clj
@@ -18,6 +18,7 @@
    [app.rpc.commands.teams :as teams]
    [app.rpc.doc :as-alias doc]
    [app.rpc.helpers :as rph]
+   [app.rpc.quotes :as quotes]
    [app.storage :as sto]
    [app.util.services :as sv]
    [app.util.time :as dt]
@@ -49,6 +50,9 @@
   [{:keys [pool] :as cfg} {:keys [team-id profile-id] :as params}]
   (let [cfg (update cfg :storage media/configure-assets-storage)]
     (teams/check-edition-permissions! pool profile-id team-id)
+    (quotes/check-quote! pool {::quotes/id ::quotes/font-variants-per-team
+                               ::quotes/profile-id profile-id
+                               ::quotes/team-id team-id})
     (create-font-variant cfg params)))
 
 (defn create-font-variant
diff --git a/backend/src/app/rpc/mutations/projects.clj b/backend/src/app/rpc/mutations/projects.clj
index 9c9188217..1a49d4fc1 100644
--- a/backend/src/app/rpc/mutations/projects.clj
+++ b/backend/src/app/rpc/mutations/projects.clj
@@ -14,6 +14,7 @@
    [app.rpc.doc :as-alias doc]
    [app.rpc.helpers :as rph]
    [app.rpc.queries.projects :as proj]
+   [app.rpc.quotes :as quotes]
    [app.util.services :as sv]
    [app.util.time :as dt]
    [clojure.spec.alpha :as s]))
@@ -37,6 +38,10 @@
   [{:keys [pool] :as cfg} {:keys [profile-id team-id] :as params}]
   (db/with-atomic [conn pool]
     (teams/check-edition-permissions! conn profile-id team-id)
+    (quotes/check-quote! conn {::quotes/id ::quotes/projects-per-team
+                               ::quotes/profile-id profile-id
+                               ::quotes/team-id team-id})
+
     (let [project (teams/create-project conn params)]
       (teams/create-project-role conn profile-id (:id project) :owner)
 
diff --git a/backend/src/app/rpc/quotes.clj b/backend/src/app/rpc/quotes.clj
new file mode 100644
index 000000000..b3c083cdd
--- /dev/null
+++ b/backend/src/app/rpc/quotes.clj
@@ -0,0 +1,327 @@
+;; 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.quotes
+  "Penpot resource usage quotes."
+  (:require
+   [app.common.exceptions :as ex]
+   [app.common.spec :as us]
+   [app.config :as cf]
+   [app.db :as db]
+   [app.util.time :as dt]
+   [app.worker :as wrk]
+   [clojure.spec.alpha :as s]
+   [cuerdas.core :as str]))
+
+(defmulti check-quote ::id)
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; PUBLIC API
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(s/def ::conn ::db/conn-or-pool)
+(s/def ::file-id ::us/uuid)
+(s/def ::team-id ::us/uuid)
+(s/def ::project-id ::us/uuid)
+(s/def ::profile-id ::us/uuid)
+(s/def ::incr (s/and int? pos?))
+(s/def ::target ::us/string)
+
+(s/def ::quote
+  (s/keys :req [::id ::profile-id]
+          :opt [::conn
+                ::team-id
+                ::project-id
+                ::file-id
+                ::incr]))
+
+(def ^:private enabled (volatile! true))
+
+(defn enable!
+  "Enable quotes checking at runtime (from server REPL)."
+  []
+  (vswap! enabled (constantly true)))
+
+(defn disable!
+  "Disable quotes checking at runtime (from server REPL)."
+  []
+  (vswap! enabled (constantly false)))
+
+(defn check-quote!
+  [conn quote]
+  (us/assert! ::db/conn-or-pool conn)
+  (us/assert! ::quote quote)
+  (when (contains? cf/flags :quotes)
+    (when @enabled
+      (check-quote (assoc quote ::conn conn ::target (name (::id quote)))))))
+
+(defn- send-notification!
+  [{:keys [::conn] :as params}]
+  (when-let [admins (seq (cf/get :admins))]
+    (let [subject (str/istr "[quotes:notification]: max quote reached ~(::target params)")
+          content (str/istr "- Param: profile-id '~(::profile-id params)}'\n"
+                            "- Param: team-id '~(::team-id params)'\n"
+                            "- Param: project-id '~(::project-id params)'\n"
+                            "- Param: file-id '~(::file-id params)'\n"
+                            "- Quote ID: '~(::target params)'\n"
+                            "- Max: ~(::quote params)\n"
+                            "- Total: ~(::total params) (INCR ~(::incr params 1))\n")]
+      (wrk/submit! {::wrk/task :sendmail
+                    ::wrk/delay (dt/duration "30s")
+                    ::wrk/max-retries 4
+                    ::wrk/priority 200
+                    ::wrk/conn conn
+                    ::wrk/dedupe true
+                    ::wrk/label "quotes-notification"
+                    :to (vec admins)
+                    :subject subject
+                    :body [{:type "text/plain"
+                            :content content}]}))))
+
+(defn- generic-check!
+  [{:keys [::conn ::incr ::quote-sql ::count-sql ::default ::target] :or {incr 1} :as params}]
+  (let [quote (->> (db/exec! conn quote-sql)
+                   (map :quote)
+                   (reduce max (- Integer/MAX_VALUE)))
+        quote (if (pos? quote) quote default)
+        total (->> (db/exec! conn count-sql) first :total)]
+
+    (when (> (+ total incr) quote)
+      (if (contains? cf/flags :soft-quotes)
+        (send-notification! (assoc params ::quote quote ::total total))
+        (ex/raise :type :restriction
+                  :code :max-quote-reached
+                  :target target
+                  :quote quote
+                  :count total)))))
+
+(def ^:private sql:get-quotes-1
+  "select id, quote from usage_quote
+    where target = ?
+      and profile_id = ?
+      and team_id is null
+      and project_id is null
+      and file_id is null;")
+
+(def ^:private sql:get-quotes-2
+  "select id, quote from usage_quote
+    where target = ?
+      and ((team_id = ? and (profile_id = ? or profile_id is null)) or
+           (profile_id = ? and team_id is null and project_id is null and file_id is null));")
+
+(def ^:private sql:get-quotes-3
+  "select id, quote from usage_quote
+    where target = ?
+      and ((project_id = ? and (profile_id = ? or profile_id is null)) or
+           (team_id = ? and (profile_id = ? or profile_id is null)) or
+           (profile_id = ? and team_id is null and project_id is null and file_id is null));")
+
+(def ^:private sql:get-quotes-4
+  "select id, quote from usage_quote
+    where target = ?
+      and ((file_id = ? and (profile_id = ? or profile_id is null)) or
+           (project_id = ? and (profile_id = ? or profile_id is null)) or
+           (team_id = ? and (profile_id = ? or profile_id is null)) or
+           (profile_id = ? and team_id is null and project_id is null and file_id is null));")
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; QUOTE: TEAMS-PER-PROFILE
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(def ^:private sql:get-teams-per-profile
+  "select count(*) as total
+     from team_profile_rel
+    where profile_id = ?")
+
+(s/def ::profile-id ::us/uuid)
+(s/def ::teams-per-profile
+  (s/keys :req [::profile-id ::target]))
+
+(defmethod check-quote ::teams-per-profile
+  [{:keys [::profile-id ::target] :as quote}]
+  (us/assert! ::teams-per-profile quote)
+  (-> quote
+      (assoc ::default (cf/get :quotes-teams-per-profile Integer/MAX_VALUE))
+      (assoc ::quote-sql [sql:get-quotes-1 target profile-id])
+      (assoc ::count-sql [sql:get-teams-per-profile profile-id])
+      (generic-check!)))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; QUOTE: PROJECTS-PER-TEAM
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(def ^:private sql:get-projects-per-team
+  "select count(*) as total
+     from project as p
+    where p.team_id = ?
+      and p.deleted_at is null")
+
+(s/def ::team-id ::us/uuid)
+(s/def ::projects-per-team
+  (s/keys :req [::profile-id ::team-id ::target]))
+
+(defmethod check-quote ::projects-per-team
+  [{:keys [::profile-id ::team-id ::target] :as quote}]
+  (-> quote
+      (assoc ::default (cf/get :quotes-projects-per-team Integer/MAX_VALUE))
+      (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
+      (assoc ::count-sql [sql:get-projects-per-team team-id])
+      (generic-check!)))
+
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; QUOTE: FONT-VARIANTS-PER-TEAM
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(def ^:private sql:get-font-variants-per-team
+  "select count(*) as total
+     from team_font_variant as v
+     where v.team_id = ?")
+
+(s/def ::font-variants-per-team
+  (s/keys :req [::profile-id ::team-id ::target]))
+
+(defmethod check-quote ::font-variants-per-team
+  [{:keys [::profile-id ::team-id ::target] :as quote}]
+  (us/assert! ::font-variants-per-team quote)
+  (-> quote
+      (assoc ::default (cf/get :quotes-font-variants-per-team Integer/MAX_VALUE))
+      (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
+      (assoc ::count-sql [sql:get-font-variants-per-team team-id])
+      (generic-check!)))
+
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; QUOTE: INVITATIONS-PER-TEAM
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(def ^:private sql:get-invitations-per-team
+  "select count(*) as total
+     from team_invitation
+    where team_id = ?")
+
+(s/def ::invitations-per-team
+  (s/keys :req [::profile-id ::team-id ::target]))
+
+(defmethod check-quote ::invitations-per-team
+  [{:keys [::profile-id ::team-id ::target] :as quote}]
+  (us/assert! ::invitations-per-team quote)
+  (-> quote
+      (assoc ::default (cf/get :quotes-invitations-per-team Integer/MAX_VALUE))
+      (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
+      (assoc ::count-sql [sql:get-invitations-per-team team-id])
+      (generic-check!)))
+
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; QUOTE: PROFILES-PER-TEAM
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(def ^:private sql:get-profiles-per-team
+  "select (select count(*)
+             from team_profile_rel
+            where team_id = ?) +
+          (select count(*)
+             from team_invitation
+            where team_id = ?
+              and valid_until > now()) as total;")
+
+;; NOTE: the total number of profiles is determined by the number of
+;; effective members plus ongoing valid invitations.
+
+(s/def ::profiles-per-team
+  (s/keys :req [::profile-id ::team-id ::target]))
+
+(defmethod check-quote ::profiles-per-team
+  [{:keys [::profile-id ::team-id ::target] :as quote}]
+  (us/assert! ::profiles-per-team quote)
+  (-> quote
+      (assoc ::default (cf/get :quotes-profiles-per-team Integer/MAX_VALUE))
+      (assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
+      (assoc ::count-sql [sql:get-profiles-per-team team-id team-id])
+      (generic-check!)))
+
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; QUOTE: FILES-PER-PROJECT
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(def ^:private sql:get-files-per-project
+  "select count(*) as total
+     from file as f
+    where f.project_id = ?
+      and f.deleted_at is null")
+
+(s/def ::project-id ::us/uuid)
+(s/def ::files-per-project
+  (s/keys :req [::profile-id ::project-id ::team-id ::target]))
+
+(defmethod check-quote ::files-per-project
+  [{:keys [::profile-id ::project-id ::team-id ::target] :as quote}]
+  (us/assert! ::files-per-project quote)
+  (-> quote
+      (assoc ::default (cf/get :quotes-files-per-project Integer/MAX_VALUE))
+      (assoc ::quote-sql [sql:get-quotes-3 target project-id profile-id team-id profile-id profile-id])
+      (assoc ::count-sql [sql:get-files-per-project project-id])
+      (generic-check!)))
+
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; QUOTE: COMMENT-THREADS-PER-FILE
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(def ^:private sql:get-comment-threads-per-file
+  "select count(*) as total
+     from comment_thread as ct
+    where ct.file_id = ?")
+
+(s/def ::comment-threads-per-file
+  (s/keys :req [::profile-id ::project-id ::team-id ::target]))
+
+(defmethod check-quote ::comment-threads-per-file
+  [{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}]
+  (us/assert! ::files-per-project quote)
+  (-> quote
+      (assoc ::default (cf/get :quotes-comment-threads-per-file Integer/MAX_VALUE))
+      (assoc ::quote-sql [sql:get-quotes-4 target project-id profile-id team-id profile-id profile-id])
+      (assoc ::count-sql [sql:get-comment-threads-per-file file-id])
+      (generic-check!)))
+
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; QUOTE: COMMENTS-PER-FILE
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(def ^:private sql:get-comments-per-file
+  "select count(*) as total
+     from comment as c
+     join comment_thread as ct on (ct.id = c.thread_id)
+    where ct.file_id = ?")
+
+(s/def ::comments-per-file
+  (s/keys :req [::profile-id ::project-id ::team-id ::target]))
+
+(defmethod check-quote ::comments-per-file
+  [{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}]
+  (us/assert! ::files-per-project quote)
+  (-> quote
+      (assoc ::default (cf/get :quotes-comments-per-file Integer/MAX_VALUE))
+      (assoc ::quote-sql [sql:get-quotes-4 target file-id profile-id project-id
+                          profile-id team-id profile-id profile-id])
+      (assoc ::count-sql [sql:get-comments-per-file file-id])
+      (generic-check!)))
+
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; QUOTE: DEFAULT
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(defmethod check-quote :default
+  [{:keys [::id]}]
+  (ex/raise :type :internal
+            :code :quote-not-defined
+            :quote id
+            :hint "backend using a quote identifier not defined"))
diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj
index 1fa09c4d7..9c3fcf75f 100644
--- a/backend/test/backend_tests/helpers.clj
+++ b/backend/test/backend_tests/helpers.clj
@@ -59,7 +59,8 @@
 (def default-flags
   [:enable-secure-session-cookies
    :enable-email-verification
-   :enable-smtp])
+   :enable-smtp
+   :enable-quotes])
 
 (defn state-init
   [next]
diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj
index 6116b8d8a..bf27cba90 100644
--- a/backend/test/backend_tests/rpc_font_test.clj
+++ b/backend/test/backend_tests/rpc_font_test.clj
@@ -6,52 +6,56 @@
 
 (ns backend-tests.rpc-font-test
   (:require
-   [backend-tests.helpers :as th]
    [app.common.uuid :as uuid]
    [app.db :as db]
    [app.http :as http]
    [app.storage :as sto]
+   [backend-tests.helpers :as th]
    [clojure.test :as t]
    [datoteka.fs :as fs]
-   [datoteka.io :as io]))
+   [datoteka.io :as io]
+   [mockery.core :refer [with-mocks]]))
 
 (t/use-fixtures :once th/state-init)
 (t/use-fixtures :each th/database-reset)
 
 (t/deftest ttf-font-upload-1
-  (let [prof    (th/create-profile* 1 {:is-active true})
-        team-id (:default-team-id prof)
-        proj-id (:default-project-id prof)
-        font-id (uuid/custom 10 1)
+  (with-mocks [mock {:target 'app.rpc.quotes/check-quote! :return nil}]
+    (let [prof    (th/create-profile* 1 {:is-active true})
+          team-id (:default-team-id prof)
+          proj-id (:default-project-id prof)
+          font-id (uuid/custom 10 1)
 
-        ttfdata (-> (io/resource "backend_tests/test_files/font-1.ttf")
-                    io/input-stream
-                    io/read-as-bytes)
+          ttfdata (-> (io/resource "backend_tests/test_files/font-1.ttf")
+                      io/input-stream
+                      io/read-as-bytes)
 
-        params  {::th/type :create-font-variant
-                 :profile-id (:id prof)
-                 :team-id team-id
-                 :font-id font-id
-                 :font-family "somefont"
-                 :font-weight 400
-                 :font-style "normal"
-                 :data {"font/ttf" ttfdata}}
-        out     (th/mutation! params)]
+          params  {::th/type :create-font-variant
+                   :profile-id (:id prof)
+                   :team-id team-id
+                   :font-id font-id
+                   :font-family "somefont"
+                   :font-weight 400
+                   :font-style "normal"
+                   :data {"font/ttf" ttfdata}}
+          out     (th/mutation! params)]
 
-    ;; (th/print-result! out)
-    (t/is (nil? (:error out)))
-    (let [result (:result out)]
-      (t/is (uuid? (:id result)))
-      (t/is (uuid? (:ttf-file-id result)))
-      (t/is (uuid? (:otf-file-id result)))
-      (t/is (uuid? (:woff1-file-id result)))
-      (t/are [k] (= (get params k)
-                    (get result k))
-        :team-id
-        :font-id
-        :font-family
-        :font-weight
-        :font-style))))
+      (t/is (= 1 (:call-count @mock)))
+
+      ;; (th/print-result! out)
+      (t/is (nil? (:error out)))
+      (let [result (:result out)]
+        (t/is (uuid? (:id result)))
+        (t/is (uuid? (:ttf-file-id result)))
+        (t/is (uuid? (:otf-file-id result)))
+        (t/is (uuid? (:woff1-file-id result)))
+        (t/are [k] (= (get params k)
+                      (get result k))
+          :team-id
+          :font-id
+          :font-family
+          :font-weight
+          :font-style)))))
 
 (t/deftest ttf-font-upload-2
   (let [prof    (th/create-profile* 1 {:is-active true})
diff --git a/backend/test/backend_tests/rpc_quotes_test.clj b/backend/test/backend_tests/rpc_quotes_test.clj
new file mode 100644
index 000000000..99dae0327
--- /dev/null
+++ b/backend/test/backend_tests/rpc_quotes_test.clj
@@ -0,0 +1,344 @@
+;; 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 backend-tests.rpc-quotes-test
+  (:require
+   [app.common.uuid :as uuid]
+   [app.db :as db]
+   [app.http :as http]
+   [app.rpc :as-alias rpc]
+   [app.rpc.cond :as cond]
+   [app.rpc.quotes :as-alias quotes]
+   [backend-tests.helpers :as th]
+   [clojure.test :as t]
+   [datoteka.core :as fs]
+   [mockery.core :refer [with-mocks]]))
+
+(t/use-fixtures :once th/state-init)
+(t/use-fixtures :each th/database-reset)
+
+(t/deftest teams-per-profile-quote
+  (with-mocks [mock {:target 'app.config/get
+                     :return (th/config-get-mock
+                              {:quotes-teams-per-profile 2})}]
+
+    (let [profile-1 (th/create-profile* 1)
+          profile-2 (th/create-profile* 2)
+          data      {::th/type :create-team
+                     ::rpc/profile-id (:id profile-1)}
+          check-ok! (fn [n]
+                      (let [data (assoc data :name (str "team" n))
+                            out  (th/command! data)]
+                        ;; (th/print-result! out)
+                        (t/is (nil? (:error out)))
+                        (t/is (some? (:result out)))))
+          check-ko! (fn [n]
+                      (let [data (assoc data :name (str "team" n))
+                            out  (th/command! data)]
+                        ;; (th/print-result! out)
+                        (t/is (not (th/success? out)))
+                        (let [error (:error out)]
+                          (t/is (= :restriction (th/ex-type error)))
+                          (t/is (= :max-quote-reached (th/ex-code error)))
+                          (t/is (= "teams-per-profile" (:target (ex-data error)))))))]
+
+      (th/db-insert! :usage-quote
+                     {:profile-id (:id profile-2)
+                      :target "teams-per-profile"
+                      :quote 100})
+
+      (check-ok! 1)
+      (check-ko! 2)
+
+      (th/db-insert! :usage-quote
+                     {:profile-id (:id profile-1)
+                      :target "teams-per-profile"
+                      :quote 3})
+
+      (check-ok! 2)
+      (check-ko! 3))))
+
+(t/deftest projects-per-team-quote
+  (with-mocks [mock {:target 'app.config/get
+                     :return (th/config-get-mock
+                              {:quotes-projects-per-team 2})}]
+
+    (let [profile-1  (th/create-profile* 1)
+          profile-2  (th/create-profile* 2)
+          team-id    (:default-team-id profile-1)
+          data       {::th/type :create-project
+                      :profile-id (:id profile-1)
+                      :team-id team-id}
+
+          check-ok! (fn [name]
+                      (let [data (assoc data :name (str "project" name))
+                            out  (th/mutation! data)]
+                        ;; (th/print-result! out)
+                        (t/is (nil? (:error out)))
+                        (t/is (some? (:result out)))))
+
+          check-ko! (fn [name]
+                      ;; create second project
+                      (let [data (assoc data :name (str "project" name))
+                            out  (th/mutation! data)]
+                        ;; (th/print-result! out)
+                        (t/is (not (th/success? out)))
+                        (let [error (:error out)]
+                          (t/is (= :restriction (th/ex-type error)))
+                          (t/is (= :max-quote-reached (th/ex-code error)))
+                          (t/is (= "projects-per-team" (:target (ex-data error)))))))]
+
+      (check-ok! 1)
+      (check-ko! 2)
+
+      (th/db-insert! :usage-quote
+                     {:team-id team-id
+                      :target "projects-per-team"
+                      :quote 3})
+
+      (th/db-insert! :usage-quote
+                     {:team-id team-id
+                      :profile-id (:id profile-2)
+                      :target "projects-per-team"
+                      :quote 10})
+
+      (check-ok! 2)
+      (check-ko! 3)
+
+      (th/db-insert! :usage-quote
+                     {:team-id team-id
+                      :profile-id (:id profile-1)
+                      :target "projects-per-team"
+                      :quote 4})
+
+      (check-ok! 3)
+      (check-ko! 4)
+
+      (th/db-insert! :usage-quote
+                     {:profile-id (:id profile-1)
+                      :target "projects-per-team"
+                      :quote 5})
+
+      (check-ok! 4)
+      (check-ko! 5)
+
+      )))
+
+(t/deftest invitations-per-team-quote
+  (with-mocks [mock {:target 'app.config/get
+                     :return (th/config-get-mock
+                              {:quotes-invitations-per-team 2})}]
+    (let [profile-1 (th/create-profile* 1)
+          profile-2 (th/create-profile* 2)
+          data      {::th/type :create-team-invitations
+                     ::rpc/profile-id (:id profile-1)
+                     :team-id (:default-team-id profile-1)
+                     :role :editor}
+
+          check-ok! (fn [n]
+                      (let [data (assoc data :emails [(str "foo" n "@example.net")])
+                            out  (th/command! data)]
+                        ;; (th/print-result! out)
+                        (t/is (nil? (:error out)))
+                        (t/is (some? (:result out)))))
+          check-ko! (fn [n]
+                      (let [data (assoc data :emails [(str "foo" n "@example.net")])
+                            out  (th/command! data)]
+                        ;; (th/print-result! out)
+                        (t/is (not (th/success? out)))
+                        (let [error (:error out)]
+                          (t/is (= :restriction (th/ex-type error)))
+                          (t/is (= :max-quote-reached (th/ex-code error)))
+                          (t/is (= "invitations-per-team" (:target (ex-data error)))))))]
+
+      (th/db-insert! :usage-quote
+                     {:profile-id (:id profile-2)
+                      :target "invitations-per-team"
+                      :quote 100})
+
+      (th/db-insert! :usage-quote
+                     {:team-id (:default-team-id profile-2)
+                      :target "invitations-per-team"
+                      :quote 100})
+
+      (check-ok! 1)
+      (check-ok! 2)
+      (check-ko! 3)
+
+      (th/db-insert! :usage-quote
+                     {:team-id (:default-team-id profile-1)
+                      :target "invitations-per-team"
+                      :quote 3})
+
+      (th/db-insert! :usage-quote
+                     {:team-id (:default-team-id profile-1)
+                      :profile-id (:id profile-2)
+                      :target "invitations-per-team"
+                      :quote 100})
+
+      (check-ok! 3)
+      (check-ko! 4)
+
+      (th/db-insert! :usage-quote
+                     {:team-id (:default-team-id profile-1)
+                      :profile-id (:id profile-1)
+                      :target "invitations-per-team"
+                      :quote 4})
+
+      (check-ok! 4)
+      (check-ko! 5)
+
+      (th/db-insert! :usage-quote
+                     {:profile-id (:id profile-1)
+                      :target "invitations-per-team"
+                      :quote 5})
+
+      (check-ok! 5)
+      (check-ko! 6))))
+
+
+(t/deftest profiles-per-team-quote
+  (with-mocks [mock {:target 'app.config/get
+                     :return (th/config-get-mock
+                              {:quotes-profiles-per-team 3})}]
+    (let [profile-1 (th/create-profile* 1)
+          profile-2 (th/create-profile* 2)
+          data      {::th/type :create-team-invitations
+                     ::rpc/profile-id (:id profile-1)
+                     :team-id (:default-team-id profile-1)
+                     :role :editor}
+
+          check-ok! (fn [n]
+                      (let [data (assoc data :emails [(str "foo" n "@example.net")])
+                            out  (th/command! data)]
+                        ;; (th/print-result! out)
+                        (t/is (nil? (:error out)))
+                        (t/is (some? (:result out)))))
+          check-ko! (fn [n]
+                      (let [data (assoc data :emails [(str "foo" n "@example.net")])
+                            out  (th/command! data)]
+                        ;; (th/print-result! out)
+                        (t/is (not (th/success? out)))
+                        (let [error (:error out)]
+                          (t/is (= :restriction (th/ex-type error)))
+                          (t/is (= :max-quote-reached (th/ex-code error)))
+                          (t/is (= "profiles-per-team" (:target (ex-data error)))))))]
+
+      (th/create-team-role* {:team-id (:default-team-id profile-1)
+                             :profile-id (:id profile-2)
+                             :role :admin})
+
+      (th/db-insert! :usage-quote
+                     {:profile-id (:id profile-2)
+                      :target "profiles-per-team"
+                      :quote 100})
+
+      (th/db-insert! :usage-quote
+                     {:team-id (:default-team-id profile-2)
+                      :target "profiles-per-team"
+                      :quote 100})
+
+
+      (check-ok! 1)
+      (check-ko! 2)
+
+      (th/db-insert! :usage-quote
+                     {:team-id (:default-team-id profile-1)
+                      :target "profiles-per-team"
+                      :quote 4})
+
+      (check-ok! 2)
+      (check-ko! 3))))
+
+
+
+(t/deftest files-per-project-quote
+  (with-mocks [mock {:target 'app.config/get
+                     :return (th/config-get-mock
+                              {:quotes-files-per-project 1})}]
+
+    (let [profile-1 (th/create-profile* 1)
+          profile-2 (th/create-profile* 2)
+          project-1 (th/create-project* 1 {:profile-id (:id profile-1)
+                                           :team-id (:default-team-id profile-1)})
+          project-2 (th/create-project* 2 {:profile-id (:id profile-2)
+                                           :team-id (:default-team-id profile-2)})
+          data      {::th/type :create-file
+                     ::rpc/profile-id (:id profile-1)
+                      :project-id (:id project-1)}
+          check-ok! (fn [n]
+                      (let [data (assoc data :name (str "file" n))
+                            out  (th/command! data)]
+                        ;; (th/print-result! out)
+                        (t/is (nil? (:error out)))
+                        (t/is (some? (:result out)))))
+          check-ko! (fn [n]
+                      (let [data (assoc data :name (str "file" n))
+                            out  (th/command! data)]
+                        ;; (th/print-result! out)
+                        (t/is (not (th/success? out)))
+                        (let [error (:error out)]
+                          (t/is (= :restriction (th/ex-type error)))
+                          (t/is (= :max-quote-reached (th/ex-code error)))
+                          (t/is (= "files-per-project" (:target (ex-data error)))))))]
+
+      (th/db-insert! :usage-quote
+                     {:project-id (:id project-2)
+                      :target "files-per-project"
+                      :quote 100})
+
+      (th/db-insert! :usage-quote
+                     {:team-id (:team-id project-2)
+                      :target "files-per-project"
+                      :quote 100})
+
+      (th/db-insert! :usage-quote
+                     {:profile-id (:id profile-2)
+                      :target "files-per-project"
+                      :quote 100})
+
+
+      (check-ok! 1)
+      (check-ko! 2)
+
+      (th/db-insert! :usage-quote
+                     {:project-id (:id project-1)
+                      :target "files-per-project"
+                      :quote 2})
+
+      (th/db-insert! :usage-quote
+                     {:project-id (:id project-1)
+                      :profile-id (:id profile-2)
+                      :target "files-per-project"
+                      :quote 100})
+
+      (check-ok! 2)
+      (check-ko! 3)
+
+      (th/db-insert! :usage-quote
+                     {:team-id (:team-id project-1)
+                      :target "files-per-project"
+                      :quote 3})
+
+      (th/db-insert! :usage-quote
+                     {:team-id (:team-id project-1)
+                      :profile-id (:id profile-2)
+                      :target "files-per-project"
+                      :quote 100})
+
+
+      (check-ok! 3)
+      (check-ko! 4)
+
+      (th/db-insert! :usage-quote
+                     {:profile-id (:id profile-1)
+                      :target "files-per-project"
+                      :quote 4})
+
+      (check-ok! 4)
+      (check-ko! 5)
+
+      )))
diff --git a/common/src/app/common/spec.cljc b/common/src/app/common/spec.cljc
index 1dd0eff35..76deea9f4 100644
--- a/common/src/app/common/spec.cljc
+++ b/common/src/app/common/spec.cljc
@@ -249,14 +249,6 @@
   (s/with-gen (s/and string? #(not (str/empty? %)))
     #(tgen/such-that (complement str/empty?) (s/gen ::string))))
 
-(s/def ::url ::string)
-(s/def ::fn fn?)
-(s/def ::id ::uuid)
-
-(s/def ::set-of-string (s/every ::string :kind set?))
-(s/def ::coll-of-uuid (s/every ::uuid))
-(s/def ::set-of-uuid (s/every ::uuid :kind set?))
-
 #?(:clj
    (s/def ::agent #(instance? clojure.lang.Agent %)))
 
@@ -300,6 +292,13 @@
   (s/with-gen safe-number? #(tgen/one-of [(s/gen ::safe-integer)
                                           (s/gen ::safe-float)])))
 
+(s/def ::url ::string)
+(s/def ::fn fn?)
+(s/def ::id ::uuid)
+(s/def ::some some?)
+(s/def ::coll-of-uuid (s/every ::uuid))
+(s/def ::set-of-uuid (s/every ::uuid :kind set?))
+
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;; MACROS
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/common/src/app/common/types/shape_tree.cljc b/common/src/app/common/types/shape_tree.cljc
index 6304264a8..d7fd2c8db 100644
--- a/common/src/app/common/types/shape_tree.cljc
+++ b/common/src/app/common/types/shape_tree.cljc
@@ -247,7 +247,7 @@
 (defn top-nested-frame-ids
   "Search the top nested frame in a list of ids"
   [objects ids]
-  
+
   (let [frame-ids (->> ids (filter #(cph/frame-shape? objects %)))
         frame-set (set frame-ids)]
     (loop [current-id (first frame-ids)]
@@ -296,11 +296,14 @@
     [p1 (+ 1 (d/parse-integer p2))]
     [basename 1]))
 
+(s/def ::set-of-strings
+  (s/every ::us/string :kind set?))
+
 (defn generate-unique-name
   "A unique name generator"
   [used basename]
-  (s/assert ::us/set-of-string used)
-  (s/assert ::us/string basename)
+  (us/assert! ::set-of-strings used)
+  (us/assert! ::us/string basename)
   (if-not (contains? used basename)
     basename
     (let [[prefix initial] (extract-numeric-suffix basename)]
@@ -355,8 +358,8 @@
            [new-object new-objects updated-objects])
 
          (let [child-id (first child-ids)
-               child (get objects child-id)
-               _ (us/assert some? child)
+               child    (get objects child-id)
+               _        (us/assert! ::us/some child)
 
                [new-child new-child-objects updated-child-objects]
                (clone-object child new-id objects update-new-object update-original-object)]
diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs
index cc5a0adfb..7c1900dd8 100644
--- a/frontend/src/app/main/data/workspace/notifications.cljs
+++ b/frontend/src/app/main/data/workspace/notifications.cljs
@@ -189,7 +189,6 @@
 (s/def ::file-change-event
   (s/keys :req-un [::type ::profile-id ::file-id ::session-id ::revn ::changes]))
 
-
 (defn handle-file-change
   [{:keys [file-id changes] :as msg}]
   (us/assert ::file-change-event msg)
diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs
index fe9eb8744..0aec80229 100644
--- a/frontend/src/app/main/errors.cljs
+++ b/frontend/src/app/main/errors.cljs
@@ -173,20 +173,18 @@
   (cond
     (= :feature-mismatch code)
     (let [message (tr "errors.feature-mismatch" (:feature error))]
-      (st/emit! (modal/show
-                 {:type :alert
-                  :message message
-                  :on-accept #(prn "kaka")})))
+      (st/emit! (modal/show {:type :alert :message message})))
 
     (= :features-not-supported code)
     (let [message (tr "errors.feature-not-supported" (:feature error))]
-      (st/emit! (modal/show
-                 {:type :alert
-                  :message message
-                  :on-accept #(prn "kaka")})))
+      (st/emit! (modal/show {:type :alert :message message})))
+
+    (= :max-quote-reached code)
+    (let [message (tr "errors.max-quote-reached" (:target error))]
+      (st/emit! (modal/show {:type :alert :message message})))
 
     :else
-    (ptk/handle-error (assoc error :type :server-error))))
+    (ptk/handle-error {:type :server-error :data error})))
 
 ;; This happens when the backed server fails to process the
 ;; request. This can be caused by an internal assertion or any other
diff --git a/frontend/translations/en.po b/frontend/translations/en.po
index 6bf469c94..9f177f8c4 100644
--- a/frontend/translations/en.po
+++ b/frontend/translations/en.po
@@ -765,6 +765,10 @@ msgstr "Your browser cannot do this operation"
 msgid "errors.feature-not-supported"
 msgstr "Feature '%s' is not supported."
 
+#: src/app/main/errors.cljs
+msgid "errors.max-quote-reached"
+msgstr "You have reached the '%s' quote. Contact with support."
+
 #: src/app/main/errors.cljs
 msgid "errors.feature-mismatch"
 msgstr "Looks like you are opening a file that has the feature '%s' enabled bug your penpot frontend does not supports it or has it disabled."
diff --git a/frontend/translations/es.po b/frontend/translations/es.po
index b3982938e..252c88ffc 100644
--- a/frontend/translations/es.po
+++ b/frontend/translations/es.po
@@ -743,6 +743,18 @@ msgstr "Webhook modificado con éxito"
 msgid "dashboard.webhooks.create.success"
 msgstr "Webhook creado con éxito"
 
+#: src/app/main/errors.cljs
+msgid "errors.feature-not-supported"
+msgstr "Caracteristica no soportada: '%s'."
+
+#: src/app/main/errors.cljs
+msgid "errors.max-quote-reached"
+msgstr "Ha alcalzando el maximo de la quota '%s'. Contacte con soporte tecnico."
+
+#: src/app/main/errors.cljs
+msgid "errors.feature-mismatch"
+msgstr "Parece que esta abriendo un fichero con la caracteristica '%s' habilitada pero la aplicacion web de penpot que esta usando no tiene soporte para ella o esta deshabilitada."
+
 msgid "errors.webhooks.timeout"
 msgstr "Timeout"
 

From 27451b97962a36d0d1161770ceb9d4cdf3872221 Mon Sep 17 00:00:00 2001
From: Andrey Antukh <niwi@niwi.nz>
Date: Fri, 30 Dec 2022 00:27:05 +0100
Subject: [PATCH 11/19] :recycle: Refactor comments RPC methods and add tests

---
 backend/src/app/config.clj                    |   4 +
 backend/src/app/rpc.clj                       |   8 +-
 backend/src/app/rpc/commands/comments.clj     | 444 +++++++++---------
 backend/src/app/rpc/commands/files.clj        |   9 +-
 backend/src/app/rpc/quotes.clj                |  14 +-
 backend/test/backend_tests/helpers.clj        |   4 +-
 .../test/backend_tests/rpc_comment_test.clj   | 290 ++++++++++++
 frontend/src/app/main/data/comments.cljs      |  27 +-
 .../src/app/main/data/workspace/comments.cljs |   1 -
 frontend/src/app/main/ui/comments.cljs        |   3 +-
 10 files changed, 571 insertions(+), 233 deletions(-)
 create mode 100644 backend/test/backend_tests/rpc_comment_test.clj

diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj
index 1fe8ddd2b..c333be29f 100644
--- a/backend/src/app/config.clj
+++ b/backend/src/app/config.clj
@@ -137,6 +137,8 @@
 (s/def ::quotes-files-per-project ::us/integer)
 (s/def ::quotes-files-per-team ::us/integer)
 (s/def ::quotes-font-variants-per-team ::us/integer)
+(s/def ::quotes-comment-threads-per-file ::us/integer)
+(s/def ::quotes-comments-per-file ::us/integer)
 
 (s/def ::default-blob-version ::us/integer)
 (s/def ::error-report-webhook ::us/string)
@@ -288,6 +290,8 @@
                    ::quotes-files-per-project
                    ::quotes-files-per-team
                    ::quotes-font-variants-per-team
+                   ::quotes-comment-threads-per-file
+                   ::quotes-comments-per-file
 
                    ::redis-uri
                    ::registration-domain-whitelist
diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj
index 7ba271128..26e39ef09 100644
--- a/backend/src/app/rpc.clj
+++ b/backend/src/app/rpc.clj
@@ -26,7 +26,7 @@
    [app.rpc.rlimit :as rlimit]
    [app.storage :as-alias sto]
    [app.util.services :as sv]
-   [app.util.time :as ts]
+   [app.util.time :as dt]
    [app.worker :as-alias wrk]
    [clojure.spec.alpha :as s]
    [integrant.core :as ig]
@@ -115,7 +115,9 @@
   [methods {:keys [profile-id session-id params] :as request} respond raise]
   (let [cmd    (keyword (:type params))
         etag   (yrq/get-header request "if-none-match")
-        data   (into {::http/request request ::cond/key etag} params)
+        data   (into {::request-at (dt/now)
+                      ::http/request request
+                      ::cond/key etag} params)
         data   (if profile-id
                  (assoc data ::profile-id profile-id ::session-id session-id)
                  (dissoc data ::profile-id))
@@ -133,7 +135,7 @@
   [{:keys [metrics ::metrics-id]} f mdata]
   (let [labels (into-array String [(::sv/name mdata)])]
     (fn [cfg params]
-      (let [tp (ts/tpoint)]
+      (let [tp (dt/tpoint)]
         (p/finally
           (f cfg params)
           (fn [_ _]
diff --git a/backend/src/app/rpc/commands/comments.clj b/backend/src/app/rpc/commands/comments.clj
index 2d0d6734f..168c5a295 100644
--- a/backend/src/app/rpc/commands/comments.clj
+++ b/backend/src/app/rpc/commands/comments.clj
@@ -6,9 +6,11 @@
 
 (ns app.rpc.commands.comments
   (:require
+   [app.common.data.macros :as dm]
    [app.common.exceptions :as ex]
    [app.common.geom.point :as gpt]
    [app.common.spec :as us]
+   [app.common.uuid :as uuid]
    [app.db :as db]
    [app.loggers.audit :as-alias audit]
    [app.loggers.webhooks :as-alias webhooks]
@@ -16,16 +18,14 @@
    [app.rpc.commands.files :as files]
    [app.rpc.commands.teams :as teams]
    [app.rpc.doc :as-alias doc]
-   [app.rpc.helpers :as rph]
-   [app.util.blob :as blob]
+   [app.rpc.quotes :as quotes]
+   [app.util.pointer-map :as pmap]
    [app.util.retry :as rtry]
    [app.util.services :as sv]
    [app.util.time :as dt]
    [clojure.spec.alpha :as s]))
 
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
-;; QUERY COMMANDS
-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; --- GENERAL PURPOSE INTERNAL HELPERS
 
 (defn decode-row
   [{:keys [participants position] :as row}]
@@ -33,9 +33,61 @@
     (db/pgpoint? position) (assoc :position (db/decode-pgpoint position))
     (db/pgobject? participants) (assoc :participants (db/decode-transit-pgobject participants))))
 
+(def sql:get-file
+  "select f.id, f.modified_at, f.revn, f.features,
+          f.project_id, p.team_id, f.data
+     from file as f
+     join project as p on (p.id = f.project_id)
+    where f.id = ?
+      and f.deleted_at is null")
+
+(defn- get-file
+  "A specialized version of get-file for comments module."
+  [conn file-id page-id]
+  (binding [pmap/*load-fn* (partial files/load-pointer conn file-id)]
+    (if-let [{:keys [data] :as file} (some-> (db/exec-one! conn [sql:get-file file-id]) (files/decode-row))]
+      (-> file
+          (assoc :page-name (dm/get-in data [:pages-index page-id :name]))
+          (assoc :page-id page-id))
+      (ex/raise :type :not-found
+                :code :object-not-found
+                :hint "file not found"))))
+
+(defn- get-comment-thread
+  [conn thread-id & {:keys [for-update?]}]
+  (-> (db/get-by-id conn :comment-thread thread-id {:for-update for-update?})
+      (decode-row)))
+
+(defn- get-comment
+  [conn comment-id & {:keys [for-update?]}]
+  (db/get-by-id conn :comment comment-id {:for-update for-update?}))
+
+(defn- get-next-seqn
+  [conn file-id]
+  (let [sql "select (f.comment_thread_seqn + 1) as next_seqn from file as f where f.id = ?"
+        res (db/exec-one! conn [sql file-id])]
+    (:next-seqn res)))
+
+(def sql:upsert-comment-thread-status
+  "insert into comment_thread_status (thread_id, profile_id, modified_at)
+   values (?, ?, ?)
+       on conflict (thread_id, profile_id)
+       do update set modified_at = ?
+   returning modified_at;")
+
+(defn upsert-comment-thread-status!
+  ([conn profile-id thread-id]
+   (upsert-comment-thread-status! conn profile-id thread-id (dt/now)))
+  ([conn profile-id thread-id mod-at]
+   (db/exec-one! conn [sql:upsert-comment-thread-status thread-id profile-id mod-at mod-at])))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; QUERY COMMANDS
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
 ;; --- COMMAND: Get Comment Threads
 
-(declare retrieve-comment-threads)
+(declare ^:private get-comment-threads)
 
 (s/def ::team-id ::us/uuid)
 (s/def ::file-id ::us/uuid)
@@ -48,9 +100,10 @@
 
 (sv/defmethod ::get-comment-threads
   {::doc/added "1.15"}
-  [{:keys [pool] :as cfg} params]
+  [{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id share-id] :as params}]
   (with-open [conn (db/open pool)]
-    (retrieve-comment-threads conn params)))
+    (files/check-comment-permissions! conn profile-id file-id share-id)
+    (get-comment-threads conn profile-id file-id)))
 
 (def sql:comment-threads
   "select distinct on (ct.id)
@@ -74,15 +127,14 @@
     where ct.file_id = ?
    window w as (partition by c.thread_id order by c.created_at asc)")
 
-(defn retrieve-comment-threads
-  [conn {:keys [::rpc/profile-id file-id share-id]}]
-  (files/check-comment-permissions! conn profile-id file-id share-id)
+(defn- get-comment-threads
+  [conn profile-id file-id]
   (->> (db/exec! conn [sql:comment-threads profile-id file-id])
        (into [] (map decode-row))))
 
 ;; --- COMMAND: Get Unread Comment Threads
 
-(declare retrieve-unread-comment-threads)
+(declare ^:private get-unread-comment-threads)
 
 (s/def ::team-id ::us/uuid)
 (s/def ::get-unread-comment-threads
@@ -94,7 +146,7 @@
   [{:keys [pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
   (with-open [conn (db/open pool)]
     (teams/check-read-permissions! conn profile-id team-id)
-    (retrieve-unread-comment-threads conn params)))
+    (get-unread-comment-threads conn profile-id team-id)))
 
 (def sql:comment-threads-by-team
   "select distinct on (ct.id)
@@ -123,19 +175,17 @@
   (str "with threads as (" sql:comment-threads-by-team ")"
        "select * from threads where count_unread_comments > 0"))
 
-(defn retrieve-unread-comment-threads
-  [conn {:keys [::rpc/profile-id team-id]}]
+(defn- get-unread-comment-threads
+  [conn profile-id team-id]
   (->> (db/exec! conn [sql:unread-comment-threads-by-team profile-id team-id])
        (into [] (map decode-row))))
 
 
 ;; --- COMMAND: Get Single Comment Thread
 
-(s/def ::id ::us/uuid)
-(s/def ::share-id (s/nilable ::us/uuid))
 (s/def ::get-comment-thread
   (s/keys :req [::rpc/profile-id]
-          :req-un [::file-id ::id]
+          :req-un [::file-id ::us/id]
           :opt-un [::share-id]))
 
 (sv/defmethod ::get-comment-thread
@@ -148,19 +198,10 @@
       (-> (db/exec-one! conn [sql profile-id file-id id])
           (decode-row)))))
 
-(defn get-comment-thread
-  [conn {:keys [::rpc/profile-id file-id id] :as params}]
-  (let [sql (str "with threads as (" sql:comment-threads ")"
-                 "select * from threads where id = ?")]
-    (-> (db/exec-one! conn [sql profile-id file-id id])
-        (decode-row))))
-
 ;; --- COMMAND: Retrieve Comments
 
-(declare get-comments)
+(declare ^:private get-comments)
 
-(s/def ::file-id ::us/uuid)
-(s/def ::share-id (s/nilable ::us/uuid))
 (s/def ::thread-id ::us/uuid)
 (s/def ::get-comments
   (s/keys :req [::rpc/profile-id]
@@ -171,16 +212,16 @@
   {::doc/added "1.15"}
   [{:keys [pool] :as cfg} {:keys [::rpc/profile-id thread-id share-id] :as params}]
   (with-open [conn (db/open pool)]
-    (let [thread (db/get-by-id conn :comment-thread thread-id)]
-      (files/check-comment-permissions! conn profile-id (:file-id thread) share-id))
-    (get-comments conn thread-id)))
+    (let [{:keys [file-id] :as thread} (get-comment-thread conn thread-id)]
+      (files/check-comment-permissions! conn profile-id file-id share-id)
+      (get-comments conn thread-id))))
 
 (def sql:comments
   "select c.* from comment as c
     where c.thread_id = ?
     order by c.created_at asc")
 
-(defn get-comments
+(defn- get-comments
   [conn thread-id]
   (->> (db/query conn :comment
                  {:thread-id thread-id}
@@ -189,26 +230,6 @@
 
 ;; --- COMMAND: Get file comments users
 
-(declare get-file-comments-users)
-
-(s/def ::file-id ::us/uuid)
-(s/def ::share-id (s/nilable ::us/uuid))
-
-(s/def ::get-profiles-for-file-comments
-  (s/keys :req [::rpc/profile-id]
-          :req-un [::file-id]
-          :opt-un [::share-id]))
-
-(sv/defmethod ::get-profiles-for-file-comments
-  "Retrieves a list of profiles with limited set of properties of all
-  participants on comment threads of the file."
-  {::doc/added "1.15"
-   ::doc/changes ["1.15" "Imported from queries and renamed."]}
-  [{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id share-id]}]
-  (with-open [conn (db/open pool)]
-    (files/check-comment-permissions! conn profile-id file-id share-id)
-    (get-file-comments-users conn file-id profile-id)))
-
 ;; All the profiles that had comment the file, plus the current
 ;; profile.
 
@@ -231,20 +252,30 @@
   [conn file-id profile-id]
   (db/exec! conn [sql:file-comment-users file-id profile-id]))
 
+(s/def ::get-profiles-for-file-comments
+  (s/keys :req [::rpc/profile-id]
+          :req-un [::file-id]
+          :opt-un [::share-id]))
+
+(sv/defmethod ::get-profiles-for-file-comments
+  "Retrieves a list of profiles with limited set of properties of all
+  participants on comment threads of the file."
+  {::doc/added "1.15"
+   ::doc/changes ["1.15" "Imported from queries and renamed."]}
+  [{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id share-id]}]
+  (with-open [conn (db/open pool)]
+    (files/check-comment-permissions! conn profile-id file-id share-id)
+    (get-file-comments-users conn file-id profile-id)))
 
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;; MUTATION COMMANDS
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 
+(declare ^:private create-comment-thread)
+
 ;; --- COMMAND: Create Comment Thread
 
-(declare upsert-comment-thread-status!)
-(declare create-comment-thread)
-(declare retrieve-page-name)
-
 (s/def ::page-id ::us/uuid)
-(s/def ::file-id ::us/uuid)
-(s/def ::share-id (s/nilable ::us/uuid))
 (s/def ::position ::gpt/point)
 (s/def ::content ::us/string)
 (s/def ::frame-id ::us/uuid)
@@ -257,63 +288,75 @@
 (sv/defmethod ::create-comment-thread
   {::doc/added "1.15"
    ::webhooks/event? true}
-  [{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id share-id] :as params}]
+  [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at file-id page-id share-id position content frame-id]}]
   (db/with-atomic [conn pool]
-    (files/check-comment-permissions! conn profile-id file-id share-id)
+    (let [{:keys [team-id project-id page-name] :as file} (get-file conn file-id page-id)]
+      (files/check-comment-permissions! conn profile-id file-id share-id)
 
-    (rtry/with-retry {::rtry/when rtry/conflict-exception?
-                      ::rtry/max-retries 3
-                      ::rtry/label "create-comment-thread"}
-      (create-comment-thread conn params))))
+      (run! (partial quotes/check-quote! conn)
+            (list {::quotes/id ::quotes/comment-threads-per-file
+                   ::quotes/profile-id profile-id
+                   ::quotes/team-id team-id
+                   ::quotes/project-id project-id
+                   ::quotes/file-id file-id}
+                  {::quotes/id ::quotes/comments-per-file
+                   ::quotes/profile-id profile-id
+                   ::quotes/team-id team-id
+                   ::quotes/project-id project-id
+                   ::quotes/file-id file-id}))
 
-(defn- retrieve-next-seqn
-  [conn file-id]
-  (let [sql "select (f.comment_thread_seqn + 1) as next_seqn from file as f where f.id = ?"
-        res (db/exec-one! conn [sql file-id])]
-    (:next-seqn res)))
-
-(defn create-comment-thread
-  [conn {:keys [::rpc/profile-id file-id page-id position content frame-id] :as params}]
-  (let [seqn    (retrieve-next-seqn conn file-id)
-        now     (dt/now)
-        pname   (retrieve-page-name conn params)
-        thread  (db/insert! conn :comment-thread
-                           {:file-id file-id
-                            :owner-id profile-id
-                            :participants (db/tjson #{profile-id})
-                            :page-name pname
-                            :page-id page-id
-                            :created-at now
-                            :modified-at now
-                            :seqn seqn
-                            :position (db/pgpoint position)
-                            :frame-id frame-id})]
+      (rtry/with-retry {::rtry/when rtry/conflict-exception?
+                        ::rtry/max-retries 3
+                        ::rtry/label "create-comment-thread"}
+        (create-comment-thread conn
+                               {:created-at request-at
+                                :profile-id profile-id
+                                :file-id file-id
+                                :page-id page-id
+                                :page-name page-name
+                                :position position
+                                :content content
+                                :frame-id frame-id})))))
 
 
-    ;; Create a comment entry
-    (db/insert! conn :comment
-                {:thread-id (:id thread)
-                 :owner-id profile-id
-                 :created-at now
-                 :modified-at now
-                 :content content})
+(defn- create-comment-thread
+  [conn {:keys [profile-id file-id page-id page-name created-at position content frame-id]}]
+  (let [;; NOTE: we take the next seq number from a separate query because the whole
+        ;; operation can be retried on conflict, and in this case the new seq shold be
+        ;; retrieved from the database.
+        seqn      (get-next-seqn conn file-id)
+        thread-id (uuid/next)
+        thread    (db/insert! conn :comment-thread
+                              {:id thread-id
+                               :file-id file-id
+                               :owner-id profile-id
+                               :participants (db/tjson #{profile-id})
+                               :page-name page-name
+                               :page-id page-id
+                               :created-at created-at
+                               :modified-at created-at
+                               :seqn seqn
+                               :position (db/pgpoint position)
+                               :frame-id frame-id})
+        comment   (db/insert! conn :comment
+                              {:id (uuid/next)
+                               :thread-id thread-id
+                               :owner-id profile-id
+                               :created-at created-at
+                               :modified-at created-at
+                               :content content})]
 
     ;; Make the current thread as read.
-    (upsert-comment-thread-status! conn profile-id (:id thread))
+    (upsert-comment-thread-status! conn profile-id thread-id created-at)
 
     ;; Optimistic update of current seq number on file.
     (db/update! conn :file
                 {:comment-thread-seqn seqn}
                 {:id file-id})
 
-    (select-keys thread [:id :file-id :page-id])))
-
-(defn- retrieve-page-name
-  [conn {:keys [file-id page-id]}]
-  (let [{:keys [data]} (db/get-by-id conn :file file-id)
-        data           (blob/decode data)]
-    (get-in data [:pages-index page-id :name])))
-
+    (-> thread
+        (select-keys [:id :file-id :page-id])
+        (assoc :comment-id (:id comment)))))
 
 ;; --- COMMAND: Update Comment Thread Status
 
@@ -329,23 +372,9 @@
   {::doc/added "1.15"}
   [{:keys [pool] :as cfg} {:keys [::rpc/profile-id id share-id] :as params}]
   (db/with-atomic [conn pool]
-    (let [cthr (db/get-by-id conn :comment-thread id {:for-update true})]
-      (when-not cthr
-        (ex/raise :type :not-found))
-
-      (files/check-comment-permissions! conn profile-id (:file-id cthr) share-id)
-      (upsert-comment-thread-status! conn profile-id (:id cthr)))))
-
-(def sql:upsert-comment-thread-status
-  "insert into comment_thread_status (thread_id, profile_id)
-   values (?, ?)
-       on conflict (thread_id, profile_id)
-       do update set modified_at = clock_timestamp()
-   returning modified_at;")
-
-(defn upsert-comment-thread-status!
-  [conn profile-id thread-id]
-  (db/exec-one! conn [sql:upsert-comment-thread-status thread-id profile-id]))
+    (let [{:keys [file-id] :as thread} (get-comment-thread conn id :for-update? true)]
+      (files/check-comment-permissions! conn profile-id file-id share-id)
+      (upsert-comment-thread-status! conn profile-id id))))
 
 
 ;; --- COMMAND: Update Comment Thread
@@ -360,12 +389,8 @@
   {::doc/added "1.15"}
   [{:keys [pool] :as cfg} {:keys [::rpc/profile-id id is-resolved share-id] :as params}]
   (db/with-atomic [conn pool]
-    (let [thread (db/get-by-id conn :comment-thread id {:for-update true})]
-      (when-not thread
-        (ex/raise :type :not-found))
-
-      (files/check-comment-permissions! conn profile-id (:file-id thread) share-id)
-
+    (let [{:keys [file-id] :as thread} (get-comment-thread conn id :for-update? true)]
+      (files/check-comment-permissions! conn profile-id file-id share-id)
       (db/update! conn :comment-thread
                   {:is-resolved is-resolved}
                   {:id id})
@@ -374,6 +399,7 @@
 
 ;; --- COMMAND: Add Comment
 
+(declare get-comment-thread)
 (declare create-comment)
 
 (s/def ::create-comment
@@ -384,66 +410,52 @@
 (sv/defmethod ::create-comment
   {::doc/added "1.15"
    ::webhooks/event? true}
-  [{:keys [pool] :as cfg} params]
+  [{:keys [pool] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at thread-id share-id content] :as params}]
   (db/with-atomic [conn pool]
-    (create-comment conn params)))
+    (let [{:keys [file-id page-id] :as thread} (get-comment-thread conn thread-id :for-update? true)
+          {:keys [team-id project-id page-name] :as file} (get-file conn file-id page-id)]
 
-(defn create-comment
-  [conn {:keys [::rpc/profile-id thread-id content share-id] :as params}]
-  (let [thread (-> (db/get-by-id conn :comment-thread thread-id {:for-update true})
-                   (decode-row))
-        pname  (retrieve-page-name conn thread)]
+      (files/check-comment-permissions! conn profile-id (:id file) share-id)
+      (quotes/check-quote! conn
+                           {::quotes/id ::quotes/comments-per-file
+                            ::quotes/profile-id profile-id
+                            ::quotes/team-id team-id
+                            ::quotes/project-id project-id
+                            ::quotes/file-id (:id file)})
 
-    ;; Standard Checks
-    (when-not thread (ex/raise :type :not-found))
+      ;; Update the page-name cached attribute on comment thread table.
+      (when (not= page-name (:page-name thread))
+        (db/update! conn :comment-thread
+                    {:page-name page-name}
+                    {:id thread-id}))
 
-    ;; Permission Checks
-    (files/check-comment-permissions! conn profile-id (:file-id thread) share-id)
+      (let [comment (db/insert! conn :comment
+                                {:id (uuid/next)
+                                 :created-at request-at
+                                 :modified-at request-at
+                                 :thread-id thread-id
+                                 :owner-id profile-id
+                                 :content content})
+            props    {:file-id file-id
+                      :share-id nil}]
 
-    ;; Update the page-name cachedattribute on comment thread table.
-    (when (not= pname (:page-name thread))
-      (db/update! conn :comment-thread
-                  {:page-name pname}
-                  {:id thread-id}))
+        ;; Update thread modified-at attribute and assoc the current
+        ;; profile to the participant set.
+        (db/update! conn :comment-thread
+                    {:modified-at request-at
+                     :participants (-> (:participants thread #{})
+                                       (conj profile-id)
+                                       (db/tjson))}
+                    {:id thread-id})
 
-    ;; NOTE: is important that all timestamptz related fields are
-    ;; created or updated on the database level for avoid clock
-    ;; inconsistencies (some user sees something read that is not
-    ;; read, etc...)
-    (let [ppants  (:participants thread #{})
-          comment (db/insert! conn :comment
-                              {:thread-id thread-id
-                               :owner-id profile-id
-                               :content content})]
+        ;; Update the current profile status in relation to the
+        ;; current thread.
+        (upsert-comment-thread-status! conn profile-id thread-id request-at)
 
-      ;; NOTE: this is done in SQL instead of using db/update!
-      ;; helper because currently the helper does not allow pass raw
-      ;; function call parameters to the underlying prepared
-      ;; statement; in a future when we fix/improve it, this can be
-      ;; changed to use the helper.
-
-      ;; Update thread modified-at attribute and assoc the current
-      ;; profile to the participant set.
-      (let [ppants (conj ppants profile-id)
-            sql    "update comment_thread
-                         set modified_at = clock_timestamp(),
-                             participants = ?
-                       where id = ?"]
-        (db/exec-one! conn [sql (db/tjson ppants) thread-id]))
-
-      ;; Update the current profile status in relation to the
-      ;; current thread.
-      (upsert-comment-thread-status! conn profile-id thread-id)
-
-      ;; Return the created comment object.
-      (rph/with-meta comment
-        {::audit/props {:file-id (:file-id thread)
-                        :share-id nil}}))))
+        (vary-meta comment assoc ::audit/props props)))))
 
 ;; --- COMMAND: Update Comment
 
-(declare update-comment)
-
 (s/def ::update-comment
   (s/keys :req [::rpc/profile-id]
           :req-un [::id ::content]
@@ -451,72 +463,70 @@
 
 (sv/defmethod ::update-comment
   {::doc/added "1.15"}
-  [{:keys [pool] :as cfg} params]
+  [{:keys [pool] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at id share-id content] :as params}]
   (db/with-atomic [conn pool]
-    (update-comment conn params)))
+    (let [{:keys [thread-id] :as comment} (get-comment conn id :for-update? true)
+          {:keys [file-id page-id owner-id] :as thread} (get-comment-thread conn thread-id :for-update? true)]
 
-(defn update-comment
-  [conn {:keys [::rpc/profile-id id content share-id] :as params}]
-  (let [comment (db/get-by-id conn :comment id {:for-update true})
-        _       (when-not comment (ex/raise :type :not-found))
-        thread  (db/get-by-id conn :comment-thread (:thread-id comment) {:for-update true})
-        _       (when-not thread (ex/raise :type :not-found))
-        pname   (retrieve-page-name conn thread)]
+      (files/check-comment-permissions! conn profile-id file-id share-id)
 
-    (files/check-comment-permissions! conn profile-id (:file-id thread) share-id)
+      ;; Don't allow edit comments to not owners
+      (when-not (= owner-id profile-id)
+        (ex/raise :type :validation
+                  :code :not-allowed))
 
-    ;; Don't allow edit comments to not owners
-    (when-not (= (:owner-id thread) profile-id)
-      (ex/raise :type :validation
-                :code :not-allowed))
-
-    (db/update! conn :comment
-                {:content content
-                 :modified-at (dt/now)}
-                {:id (:id comment)})
-
-    (db/update! conn :comment-thread
-                {:modified-at (dt/now)
-                 :page-name pname}
-                {:id (:id thread)})
-    nil))
+      (let [{:keys [page-name] :as file} (get-file conn file-id page-id)]
+        (db/update! conn :comment
+                    {:content content
+                     :modified-at request-at}
+                    {:id id})
 
+        (db/update! conn :comment-thread
+                    {:modified-at request-at
+                     :page-name page-name}
+                    {:id thread-id})
+        nil))))
 
 ;; --- COMMAND: Delete Comment Thread
 
 (s/def ::delete-comment-thread
   (s/keys :req [::rpc/profile-id]
-          :req-un [::id]))
+          :req-un [::id]
+          :opt-un [::share-id]))
 
 (sv/defmethod ::delete-comment-thread
   {::doc/added "1.15"}
-  [{:keys [pool] :as cfg} {:keys [::rpc/profile-id id] :as params}]
+  [{:keys [pool] :as cfg} {:keys [::rpc/profile-id id share-id] :as params}]
   (db/with-atomic [conn pool]
-    (let [thread (db/get-by-id conn :comment-thread id {:for-update true})]
-      (when-not (= (:owner-id thread) profile-id)
+    (let [{:keys [owner-id file-id] :as thread} (get-comment-thread conn id :for-update? true)]
+      (files/check-comment-permissions! conn profile-id file-id share-id)
+      (when-not (= owner-id profile-id)
         (ex/raise :type :validation
                   :code :not-allowed))
+
       (db/delete! conn :comment-thread {:id id})
       nil)))
 
-
 ;; --- COMMAND: Delete comment
 
 (s/def ::delete-comment
   (s/keys :req [::rpc/profile-id]
-          :req-un [::id]))
+          :req-un [::id]
+          :opt-un [::share-id]))
 
 (sv/defmethod ::delete-comment
   {::doc/added "1.15"}
-  [{:keys [pool] :as cfg} {:keys [::rpc/profile-id id] :as params}]
+  [{:keys [pool] :as cfg} {:keys [::rpc/profile-id id share-id] :as params}]
   (db/with-atomic [conn pool]
-    (let [comment (db/get-by-id conn :comment id {:for-update true})]
-      (when-not (= (:owner-id comment) profile-id)
+    (let [{:keys [owner-id thread-id] :as comment} (get-comment conn id :for-update? true)
+          {:keys [file-id] :as thread} (get-comment-thread conn thread-id)]
+      (files/check-comment-permissions! conn profile-id file-id share-id)
+      (when-not (= owner-id profile-id)
         (ex/raise :type :validation
                   :code :not-allowed))
-
       (db/delete! conn :comment {:id id}))))
 
+
 ;; --- COMMAND: Update comment thread position
 
 (s/def ::update-comment-thread-position
@@ -528,10 +538,10 @@
   {::doc/added "1.15"}
   [{:keys [pool] :as cfg} {:keys [::rpc/profile-id id position frame-id share-id] :as params}]
   (db/with-atomic [conn pool]
-    (let [thread (db/get-by-id conn :comment-thread id {:for-update true})]
-      (files/check-comment-permissions! conn profile-id (:file-id thread) share-id)
+    (let [{:keys [file-id] :as thread} (get-comment-thread conn id :for-update? true)]
+      (files/check-comment-permissions! conn profile-id file-id share-id)
       (db/update! conn :comment-thread
-                  {:modified-at (dt/now)
+                  {:modified-at (::rpc/request-at params)
                    :position (db/pgpoint position)
                    :frame-id frame-id}
                   {:id (:id thread)})
@@ -548,10 +558,10 @@
   {::doc/added "1.15"}
   [{:keys [pool] :as cfg} {:keys [::rpc/profile-id id frame-id share-id] :as params}]
   (db/with-atomic [conn pool]
-    (let [thread (db/get-by-id conn :comment-thread id {:for-update true})]
-      (files/check-comment-permissions! conn profile-id (:file-id thread) share-id)
+    (let [{:keys [file-id] :as thread} (get-comment-thread conn id :for-update? true)]
+      (files/check-comment-permissions! conn profile-id file-id share-id)
       (db/update! conn :comment-thread
-                  {:modified-at (dt/now)
+                  {:modified-at (::rpc/request-at params)
                    :frame-id frame-id}
-                  {:id (:id thread)})
+                  {:id id})
       nil)))
diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj
index 054bf22ff..affa92e57 100644
--- a/backend/src/app/rpc/commands/files.clj
+++ b/backend/src/app/rpc/commands/files.clj
@@ -151,11 +151,14 @@
 (def check-read-permissions!
   (perms/make-check-fn has-read-permissions?))
 
-;; A user has comment permissions if she has read permissions, or comment permissions
+;; A user has comment permissions if she has read permissions, or
+;; explicit comment permissions through the share-id
+
 (defn check-comment-permissions!
   [conn profile-id file-id share-id]
-  (let [can-read (has-read-permissions? conn profile-id file-id)
-        can-comment  (has-comment-permissions? conn profile-id file-id share-id)]
+  (let [perms       (get-permissions conn profile-id file-id share-id)
+        can-read    (has-read-permissions? perms)
+        can-comment (has-comment-permissions? perms)]
     (when-not (or can-read can-comment)
       (ex/raise :type :not-found
                 :code :object-not-found
diff --git a/backend/src/app/rpc/quotes.clj b/backend/src/app/rpc/quotes.clj
index b3c083cdd..76cfc82f7 100644
--- a/backend/src/app/rpc/quotes.clj
+++ b/backend/src/app/rpc/quotes.clj
@@ -8,6 +8,7 @@
   "Penpot resource usage quotes."
   (:require
    [app.common.exceptions :as ex]
+   [app.common.logging :as l]
    [app.common.spec :as us]
    [app.config :as cf]
    [app.db :as db]
@@ -60,6 +61,16 @@
 
 (defn- send-notification!
   [{:keys [::conn] :as params}]
+  (l/warn :hint "max quote reached"
+          :target (::target params)
+          :profile-id (some-> params ::profile-id str)
+          :team-id (some-> params ::team-id str)
+          :project-id (some-> params ::project-id str)
+          :file-id (some-> params ::file-id str)
+          :quote (::quote params)
+          :total (::total params)
+          :incr  (::inc params 1))
+
   (when-let [admins (seq (cf/get :admins))]
     (let [subject (str/istr "[quotes:notification]: max quote reached ~(::target params)")
           content (str/istr "- Param: profile-id '~(::profile-id params)}'\n"
@@ -286,7 +297,8 @@
   (us/assert! ::files-per-project quote)
   (-> quote
       (assoc ::default (cf/get :quotes-comment-threads-per-file Integer/MAX_VALUE))
-      (assoc ::quote-sql [sql:get-quotes-4 target project-id profile-id team-id profile-id profile-id])
+      (assoc ::quote-sql [sql:get-quotes-4 target file-id profile-id project-id
+                          profile-id team-id profile-id profile-id])
       (assoc ::count-sql [sql:get-comment-threads-per-file file-id])
       (generic-check!)))
 
diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj
index 9c3fcf75f..a14dd4374 100644
--- a/backend/test/backend_tests/helpers.clj
+++ b/backend/test/backend_tests/helpers.clj
@@ -323,7 +323,9 @@
   [{:keys [::type] :as data}]
   (let [method-fn (get-in *system* [:app.rpc/methods :commands type])]
     ;; (app.common.pprint/pprint (:app.rpc/methods *system*))
-    (try-on! (method-fn (dissoc data ::type)))))
+    (try-on! (method-fn (-> data
+                            (dissoc ::type)
+                            (assoc :app.rpc/request-at (dt/now)))))))
 
 (defn mutation!
   [{:keys [::type profile-id] :as data}]
diff --git a/backend/test/backend_tests/rpc_comment_test.clj b/backend/test/backend_tests/rpc_comment_test.clj
new file mode 100644
index 000000000..d088f8b06
--- /dev/null
+++ b/backend/test/backend_tests/rpc_comment_test.clj
@@ -0,0 +1,290 @@
+;; 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 backend-tests.rpc-comment-test
+  (:require
+   [app.common.geom.point :as gpt]
+   [app.common.uuid :as uuid]
+   [app.db :as db]
+   [app.http :as http]
+   [app.rpc :as-alias rpc]
+   [app.rpc.commands.comments :as comments]
+   [app.rpc.cond :as cond]
+   [app.rpc.quotes :as-alias quotes]
+   [app.util.time :as dt]
+   [backend-tests.helpers :as th]
+   [clojure.test :as t]
+   [datoteka.core :as fs]
+   [mockery.core :refer [with-mocks]]))
+
+(t/use-fixtures :once th/state-init)
+(t/use-fixtures :each th/database-reset)
+
+(t/deftest comment-and-threads-crud
+  (with-mocks [mock {:target 'app.config/get
+                     :return (th/config-get-mock
+                              {:quotes-teams-per-profile 200})}]
+
+    (let [profile-1  (th/create-profile* 1 {:is-active true})
+          profile-2  (th/create-profile* 2 {:is-active true})
+
+          team       (th/create-team* 1 {:profile-id (:id profile-1)})
+          ;; role      (th/create-team-role* {:team-id (:id team)
+          ;;                                  :profile-id (:id profile-2)
+          ;;                                  :role :admin})
+
+          project    (th/create-project* 1 {:team-id (:id team)
+                                            :profile-id (:id profile-1)})
+          file-1     (th/create-file* 1 {:profile-id (:id profile-1)
+                                         :project-id (:id project)})
+          file-2     (th/create-file* 2 {:profile-id (:id profile-1)
+                                         :project-id (:id project)})
+          page-id    (get-in file-1 [:data :pages 0])]
+
+      (t/testing "comment thread creation"
+        (let [data {::th/type :create-comment-thread
+                    ::rpc/profile-id (:id profile-1)
+                    :file-id (:id file-1)
+                    :page-id page-id
+                    :position (gpt/point 0)
+                    :content "hello world"
+                    :frame-id uuid/zero}
+              out  (th/command! data)]
+          ;; (th/print-result! out)
+          (t/is (th/success? out))
+          (let [result (:result out)]
+            (t/is (uuid? (:id result)))
+            (t/is (uuid? (:file-id result)))
+            (t/is (uuid? (:page-id result)))
+            (t/is (uuid? (:comment-id result)))
+            (t/is (= (:file-id result) (:id file-1)))
+            (t/is (= (:page-id result) page-id)))))
+
+      (t/testing "comment thread status update"
+        (let [thread  (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
+              ;; comment (-> (th/db-query :comment {:thread-id (:id thread)}) first)
+              data    {::th/type :update-comment-thread-status
+                       ::rpc/profile-id (:id profile-1)
+                       :id (:id thread)}
+              status  (th/db-get :comment-thread-status
+                                 {:thread-id (:id thread)
+                                  :profile-id (:id profile-1)})]
+
+
+          (t/is (= (:modified-at status) (:modified-at thread)))
+
+          (let [{:keys [result] :as out} (th/command! data)]
+            (t/is (th/success? out))
+            (t/is (dt/instant? (:modified-at result))))
+
+          (let [status' (th/db-get :comment-thread-status
+                                   {:thread-id (:id thread)
+                                    :profile-id (:id profile-1)})]
+            (t/is (not= (:modified-at status') (:modified-at thread))))))
+
+      (t/testing "comment thread status update 2"
+        (let [thread  (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
+              data    {::th/type :update-comment-thread-status
+                       ::rpc/profile-id (:id profile-2)
+                       :id (:id thread)}]
+
+          (let [{:keys [error] :as out} (th/command! data)]
+            ;; (th/print-result! out)
+            (t/is (not (th/success? out)))
+            (t/is (= :not-found (th/ex-type error))))))
+
+      (t/testing "update comment thread"
+        (let [thread  (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
+              data    {::th/type :update-comment-thread
+                       ::rpc/profile-id (:id profile-1)
+                       :is-resolved true
+                       :id (:id thread)}]
+
+          (t/is (false? (:is-resolved thread)))
+
+          (let [{:keys [result] :as out} (th/command! data)]
+            (t/is (th/success? out))
+            (t/is (nil? result)))
+
+          (let [thread (th/db-get :comment-thread {:id (:id thread)})]
+            (t/is (true? (:is-resolved thread))))))
+
+      (t/testing "create comment"
+        (let [thread  (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
+              data    {::th/type :create-comment
+                       ::rpc/profile-id (:id profile-1)
+                       :thread-id (:id thread)
+                       :content "comment 2"}]
+          (let [{:keys [result] :as out} (th/command! data)
+                {:keys [modified-at]}    (th/db-get :comment-thread-status
+                                                    {:thread-id (:id thread)
+                                                     :profile-id (:id profile-1)})]
+            ;; (th/print-result! out)
+            (t/is (th/success? out))
+            (t/is (uuid? (:id result)))
+            (t/is (= (:owner-id result) (:id profile-1)))
+            (t/is (:modified-at result) modified-at))))
+
+      (t/testing "update comment"
+        (let [thread  (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
+              comment (-> (th/db-query :comment {:thread-id (:id thread) :content "comment 2"}) first)
+              data    {::th/type :update-comment
+                       ::rpc/profile-id (:id profile-1)
+                       :id (:id comment)
+                       :content "comment 2 mod"}]
+          (let [{:keys [result] :as out} (th/command! data)]
+            ;; (th/print-result! out)
+            (t/is (th/success? out))
+            (t/is (nil? result)))
+
+          (let [comment' (th/db-get :comment {:id (:id comment)})]
+            (t/is (not= (:modified-at comment) (:modified-at comment')))
+            (t/is (= (:content data) (:content comment'))))))
+
+
+      (t/testing "retrieve threads"
+        (let [data {::th/type :get-comment-threads
+                    ::rpc/profile-id (:id profile-1)
+                    :file-id (:id file-1)}
+              out  (th/command! data)]
+          ;; (th/print-result! out)
+          (t/is (th/success? out))
+          (let [[thread :as result] (:result out)]
+            (t/is (= 1 (count result)))
+            (t/is (= "Page-1" (:page-name thread)))
+            (t/is (= "hello world" (:content thread)))
+            (t/is (= 2 (:count-comments thread)))
+            (t/is (true? (:is-resolved thread))))))
+
+
+      (t/testing "unread comment threads"
+        (let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
+              data   {::th/type :get-unread-comment-threads
+                      ::rpc/profile-id (:id profile-1)}]
+
+          (let [{:keys [result] :as out} (th/command! (assoc data :team-id (:default-team-id profile-1)))]
+            (t/is (th/success? out))
+            (t/is (= [] result)))
+
+          (let [{:keys [error] :as out} (th/command! (assoc data :team-id (:default-team-id profile-2)))]
+            (t/is (not (th/success? out)))
+            (t/is (= :not-found (th/ex-type error))))
+
+          (let [{:keys [result] :as out} (th/command! (assoc data :team-id (:id team)))]
+            ;; (th/print-result! out)
+            (t/is (th/success? out))
+            (let [[thread :as result] (:result out)]
+              (t/is (= 1 (count result)))))
+
+          (let [data {::th/type :update-comment-thread-status
+                      ::rpc/profile-id (:id profile-1)
+                      :id (:id thread)}
+                out  (th/command! data)]
+            (t/is (th/success? out)))
+
+          (let [{:keys [result] :as out} (th/command! (assoc data :team-id (:id team)))]
+            ;; (th/print-result! out)
+            (t/is (th/success? out))
+            (let [result (:result out)]
+              (t/is (= 0 (count result)))))))
+
+      (t/testing "get comment thread"
+        (let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
+              data   {::th/type :get-comment-thread
+                      ::rpc/profile-id (:id profile-1)
+                      :file-id (:id file-1)
+                      :id (:id thread)}]
+
+          (let [{:keys [result] :as out} (th/command! data)]
+            ;; (th/print-result! out)
+            (t/is (th/success? out))
+            (t/is (= (:id thread) (:id result))))))
+
+      (t/testing "get comments"
+        (let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
+              data   {::th/type :get-comments
+                      ::rpc/profile-id (:id profile-1)
+                      :thread-id (:id thread)}
+              out    (th/command! data)]
+          ;; (th/print-result! out)
+          (t/is (th/success? out))
+          (let [comments (:result out)]
+            (t/is (= 2 (count comments))))))
+
+      (t/testing "get profiles"
+        (let [data {::th/type :get-profiles-for-file-comments
+                    ::rpc/profile-id (:id profile-1)
+                    :file-id (:id file-1)}
+              out  (th/command! data)]
+          ;; (th/print-result! out)
+          (t/is (th/success? out))
+          (let [[profile :as profiles] (:result out)]
+            (t/is (= 1 (count profiles)))
+            (t/is (= (:id profile-1) (:id profile))))))
+
+      (t/testing "get profiles 2"
+        (let [data {::th/type :get-profiles-for-file-comments
+                    ::rpc/profile-id (:id profile-2)
+                    :file-id (:id file-1)}
+              out  (th/command! data)]
+          ;; (th/print-result! out)
+          (t/is (not (th/success? out)))
+          (t/is (= :not-found (th/ex-type (:error out))))))
+
+      (t/testing "delete comment"
+        (let [thread  (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
+              comment (-> (th/db-query :comment {:thread-id (:id thread) :content "comment 2 mod"}) first)
+              data    {::th/type :delete-comment
+                       ::rpc/profile-id (:id profile-2)
+                       :id (:id comment)}
+              out     (th/command! data)]
+
+          ;; (th/print-result! out)
+          (t/is (not (th/success? out)))
+          (t/is (= :not-found (th/ex-type (:error out))))
+          (let [comments (th/db-query :comment {:thread-id (:id thread)})]
+            (t/is (= 2 (count comments))))))
+
+      (t/testing "delete comment 2"
+        (let [thread  (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
+              comment (-> (th/db-query :comment {:thread-id (:id thread) :content "comment 2 mod"}) first)
+              data    {::th/type :delete-comment
+                       ::rpc/profile-id (:id profile-1)
+                       :id (:id comment)}
+              out     (th/command! data)]
+
+          ;; (th/print-result! out)
+          (t/is (th/success? out))
+          (let [comments (th/db-query :comment {:thread-id (:id thread)})]
+            (t/is (= 1 (count comments))))))
+
+      (t/testing "delete comment thread"
+        (let [thread  (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
+              data    {::th/type :delete-comment-thread
+                       ::rpc/profile-id (:id profile-2)
+                       :id (:id thread)}
+              out     (th/command! data)]
+
+          ;; (th/print-result! out)
+          (t/is (not (th/success? out)))
+          (t/is (= :not-found (th/ex-type (:error out))))
+          (let [threads (th/db-query :comment-thread {:file-id (:id file-1)})]
+            (t/is (= 1 (count threads))))))
+
+      (t/testing "delete comment thread 2"
+        (let [thread  (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
+              data    {::th/type :delete-comment-thread
+                       ::rpc/profile-id (:id profile-1)
+                       :id (:id thread)}
+              out     (th/command! data)]
+
+          ;; (th/print-result! out)
+          (t/is (th/success? out))
+
+          (let [threads (th/db-query :comment-thread {:file-id (:id file-1)})]
+            (t/is (= 0 (count threads))))))
+
+      )))
diff --git a/frontend/src/app/main/data/comments.cljs b/frontend/src/app/main/data/comments.cljs
index 9b87ec18a..aced66cdc 100644
--- a/frontend/src/app/main/data/comments.cljs
+++ b/frontend/src/app/main/data/comments.cljs
@@ -96,7 +96,11 @@
         (->> (rp/cmd! :create-comment-thread params)
              (rx/mapcat #(rp/cmd! :get-comment-thread {:file-id (:file-id %) :id (:id %)}))
              (rx/map created-thread-on-workspace)
-             (rx/catch #(rx/throw {:type :comment-error})))))))
+             (rx/catch (fn [{:keys [type code] :as cause}]
+                         (if (and (= type :restriction)
+                                  (= code :max-quote-reached))
+                           (rx/throw cause)
+                           (rx/throw {:type :comment-error})))))))))
 
 (defn created-thread-on-viewer
   [{:keys [id comment page-id] :as thread}]
@@ -114,8 +118,7 @@
 
 (defn create-thread-on-viewer
   [params]
-  (us/assert ::create-thread-on-viewer-params params)
-
+  (us/assert! ::create-thread-on-viewer-params params)
   (ptk/reify ::create-thread-on-viewer
     ptk/WatchEvent
     (watch [_ state _]
@@ -125,7 +128,11 @@
         (->> (rp/cmd! :create-comment-thread params)
              (rx/mapcat #(rp/cmd! :get-comment-thread {:file-id (:file-id %) :id (:id %) :share-id share-id}))
              (rx/map created-thread-on-viewer)
-             (rx/catch #(rx/throw {:type :comment-error})))))))
+             (rx/catch (fn [{:keys [type code] :as cause}]
+                         (if (and (= type :restriction)
+                                  (= code :max-quote-reached))
+                           (rx/throw cause)
+                           (rx/throw {:type :comment-error})))))))))
 
 (defn update-comment-thread-status
   [{:keys [id] :as thread}]
@@ -154,7 +161,11 @@
     (watch [_ state _]
        (let [share-id (-> state :viewer-local :share-id)]
          (->> (rp/cmd! :update-comment-thread {:id id :is-resolved is-resolved :share-id share-id})
-              (rx/catch #(rx/throw {:type :comment-error}))
+              (rx/catch (fn [{:keys [type code] :as cause}]
+                          (if (and (= type :restriction)
+                                   (= code :max-quote-reached))
+                            (rx/throw cause)
+                            (rx/throw {:type :comment-error}))))
               (rx/ignore))))))
 
 (defn add-comment
@@ -170,7 +181,11 @@
            (rx/concat
             (->> (rp/cmd! :create-comment {:thread-id (:id thread) :content content :share-id share-id})
                  (rx/map #(partial created %))
-                 (rx/catch #(rx/throw {:type :comment-error})))
+                 (rx/catch (fn [{:keys [type code] :as cause}]
+                             (if (and (= type :restriction)
+                                      (= code :max-quote-reached))
+                               (rx/throw cause)
+                               (rx/throw {:type :comment-error})))))
             (rx/of (refresh-comment-thread thread))))))))
 
 (defn update-comment
diff --git a/frontend/src/app/main/data/workspace/comments.cljs b/frontend/src/app/main/data/workspace/comments.cljs
index 571e59e1f..25f59ffe2 100644
--- a/frontend/src/app/main/data/workspace/comments.cljs
+++ b/frontend/src/app/main/data/workspace/comments.cljs
@@ -39,7 +39,6 @@
               (rx/filter ms/mouse-click?)
               (rx/switch-map #(rx/take 1 ms/mouse-position))
               (rx/with-latest-from ms/keyboard-space)
-              (rx/tap prn)
               (rx/filter (fn [[_ space]] (not space)) )
               (rx/map first)
               (rx/map handle-comment-layer-click)
diff --git a/frontend/src/app/main/ui/comments.cljs b/frontend/src/app/main/ui/comments.cljs
index d8d451307..ea2a00ab9 100644
--- a/frontend/src/app/main/ui/comments.cljs
+++ b/frontend/src/app/main/ui/comments.cljs
@@ -6,6 +6,7 @@
 
 (ns app.main.ui.comments
   (:require
+   [app.common.data.macros :as dm]
    [app.common.geom.point :as gpt]
    [app.config :as cfg]
    [app.main.data.comments :as dcm]
@@ -333,7 +334,7 @@
                           :thread thread
                           :origin origin}]
         (for [item (rest comments)]
-          [:*
+          [:* {:key (dm/str (:id item))}
            [:hr]
            [:& comment-item {:comment item
                              :users users

From e372e8ba3e9abf1a9e6c9b8b8633fca7b968c074 Mon Sep 17 00:00:00 2001
From: Andrey Antukh <niwi@niwi.nz>
Date: Sat, 31 Dec 2022 16:37:42 +0100
Subject: [PATCH 12/19] :bug: Fix s3 client issues with s3 compatible services

---
 backend/deps.edn               |  3 +-
 backend/src/app/storage/s3.clj | 86 +++++++++++++++++++---------------
 2 files changed, 49 insertions(+), 40 deletions(-)

diff --git a/backend/deps.edn b/backend/deps.edn
index 4e9256efa..51898e67c 100644
--- a/backend/deps.edn
+++ b/backend/deps.edn
@@ -55,7 +55,8 @@
 
   ;; Pretty Print specs
   pretty-spec/pretty-spec {:mvn/version "0.1.4"}
-  software.amazon.awssdk/s3 {:mvn/version "2.19.5"}}
+  software.amazon.awssdk/s3 {:mvn/version "2.19.8"}
+  }
 
  :paths ["src" "resources" "target/classes"]
  :aliases
diff --git a/backend/src/app/storage/s3.clj b/backend/src/app/storage/s3.clj
index 0f6d017d7..6933b3d41 100644
--- a/backend/src/app/storage/s3.clj
+++ b/backend/src/app/storage/s3.clj
@@ -24,7 +24,9 @@
   (:import
    java.io.FilterInputStream
    java.io.InputStream
+   java.net.URI
    java.nio.ByteBuffer
+   java.nio.file.Path
    java.time.Duration
    java.util.Collection
    java.util.Optional
@@ -40,6 +42,7 @@
    software.amazon.awssdk.http.nio.netty.SdkEventLoopGroup
    software.amazon.awssdk.regions.Region
    software.amazon.awssdk.services.s3.S3AsyncClient
+   software.amazon.awssdk.services.s3.S3Configuration
    software.amazon.awssdk.services.s3.model.Delete
    software.amazon.awssdk.services.s3.model.DeleteObjectRequest
    software.amazon.awssdk.services.s3.model.DeleteObjectsRequest
@@ -151,46 +154,51 @@
 
 (defn build-s3-client
   [{:keys [region endpoint executor]}]
-  (let [hclient (.. (NettyNioAsyncHttpClient/builder)
-                    (eventLoopGroupBuilder (.. (SdkEventLoopGroup/builder)
-                                               (numberOfThreads (int default-eventloop-threads))))
-                    (connectionAcquisitionTimeout default-timeout)
-                    (connectionTimeout default-timeout)
-                    (readTimeout default-timeout)
-                    (writeTimeout default-timeout)
-                    (build))
-        client  (.. (S3AsyncClient/builder)
-                    (asyncConfiguration (.. (ClientAsyncConfiguration/builder)
-                                            (advancedOption SdkAdvancedAsyncClientOption/FUTURE_COMPLETION_EXECUTOR
-                                                            executor)
-                                            (build)))
-                    (httpClient hclient)
-                    (region (lookup-region region)))]
+  (let [aconfig (-> (ClientAsyncConfiguration/builder)
+                    (.advancedOption SdkAdvancedAsyncClientOption/FUTURE_COMPLETION_EXECUTOR executor)
+                    (.build))
 
-    (when-let [uri (some-> endpoint (java.net.URI.))]
-      (.endpointOverride client uri))
+        sconfig (-> (S3Configuration/builder)
+                    (cond-> (some? endpoint) (.pathStyleAccessEnabled true))
+                    (.build))
 
-    (let [client (.build client)]
-      (reify
-        clojure.lang.IDeref
-        (deref [_] client)
+        hclient (-> (NettyNioAsyncHttpClient/builder)
+                    (.eventLoopGroupBuilder (-> (SdkEventLoopGroup/builder)
+                                                (.numberOfThreads (int default-eventloop-threads))))
+                    (.connectionAcquisitionTimeout default-timeout)
+                    (.connectionTimeout default-timeout)
+                    (.readTimeout default-timeout)
+                    (.writeTimeout default-timeout)
+                    (.build))
 
-        java.lang.AutoCloseable
-        (close [_]
-          (.close hclient)
-          (.close client))))))
+        client  (-> (S3AsyncClient/builder)
+                    (.serviceConfiguration ^S3Configuration sconfig)
+                    (.asyncConfiguration  ^ClientAsyncConfiguration aconfig)
+                    (.httpClient  ^NettyNioAsyncHttpClient hclient)
+                    (.region (lookup-region region))
+                    (cond-> (some? endpoint) (.endpointOverride (URI. endpoint)))
+                    (.build))]
+
+    (reify
+      clojure.lang.IDeref
+      (deref [_] client)
+
+      java.lang.AutoCloseable
+      (close [_]
+        (.close ^NettyNioAsyncHttpClient hclient)
+        (.close ^S3AsyncClient client)))))
 
 (defn build-s3-presigner
   [{:keys [region endpoint]}]
-  (if (string? endpoint)
-    (let [uri (java.net.URI. endpoint)]
-      (.. (S3Presigner/builder)
-          (endpointOverride uri)
-          (region (lookup-region region))
-          (build)))
-    (.. (S3Presigner/builder)
-        (region (lookup-region region))
-        (build))))
+  (let [config (-> (S3Configuration/builder)
+                   (cond-> (some? endpoint) (.pathStyleAccessEnabled true))
+                   (.build))]
+
+    (-> (S3Presigner/builder)
+        (cond-> (some? endpoint) (.endpointOverride (URI. endpoint)))
+        (.region (lookup-region region))
+        (.serviceConfiguration ^S3Configuration config)
+        (.build))))
 
 (defn- make-request-body
   [content]
@@ -198,7 +206,7 @@
         buff-size (* 1024 64)
         sem       (Semaphore. 0)
 
-        writer-fn (fn [s]
+        writer-fn (fn [^Subscriber s]
                     (try
                       (loop []
                         (.acquire sem 1)
@@ -261,7 +269,7 @@
     ;; not, read the contento into memory using bytearrays.
     (if (> size (* 1024 1024 2))
       (p/let [path (tmp/tempfile :prefix "penpot.storage.s3.")
-              rxf  (AsyncResponseTransformer/toFile path)
+              rxf  (AsyncResponseTransformer/toFile ^Path path)
               _    (.getObject ^S3AsyncClient client
                                ^GetObjectRequest gor
                                ^AsyncResponseTransformer rxf)]
@@ -283,9 +291,9 @@
                   (key (str prefix (impl/id->path id)))
                   (build))
           rxf (AsyncResponseTransformer/toBytes)
-          obj (.getObjectAsBytes ^S3AsyncClient client
-                                 ^GetObjectRequest gor
-                                 ^AsyncResponseTransformer rxf)]
+          obj (.getObject ^S3AsyncClient client
+                          ^GetObjectRequest gor
+                          ^AsyncResponseTransformer rxf)]
     (.asByteArray ^ResponseBytes obj)))
 
 (def default-max-age

From 7e21d827c97584ac0bbafed54cf49be2ba3edf57 Mon Sep 17 00:00:00 2001
From: Alejandro Alonso <alejandroalonsofernandez@gmail.com>
Date: Mon, 2 Jan 2023 08:41:21 +0100
Subject: [PATCH 13/19] :bug: Fix duplicate frame issues

---
 .../app/main/data/workspace/selection.cljs    | 23 +++++++++++++------
 1 file changed, 16 insertions(+), 7 deletions(-)

diff --git a/frontend/src/app/main/data/workspace/selection.cljs b/frontend/src/app/main/data/workspace/selection.cljs
index 3ef4a4bf6..838f21812 100644
--- a/frontend/src/app/main/data/workspace/selection.cljs
+++ b/frontend/src/app/main/data/workspace/selection.cljs
@@ -23,6 +23,7 @@
    [app.main.data.workspace.collapse :as dwc]
    [app.main.data.workspace.state-helpers :as wsh]
    [app.main.data.workspace.thumbnails :as dwt]
+   [app.main.data.workspace.undo :as dwu]
    [app.main.data.workspace.zoom :as dwz]
    [app.main.refs :as refs]
    [app.main.streams :as ms]
@@ -502,8 +503,11 @@
   [obj state objects]
   (let [{:keys [id-original id-duplicated]}
         (get-in state [:workspace-local :duplicated])]
-    (if (and (not= id-original (:id obj))
-             (not= id-duplicated (:id obj)))
+    (if (or (and (not= id-original (:id obj))
+                 (not= id-duplicated (:id obj)))
+            ;; As we can remove duplicated elements may be we can still caching a deleted id
+            (not (contains? objects id-original))
+            (not (contains? objects id-duplicated)))
 
       ;; The default is leave normal shapes in place, but put
       ;; new frames to the right of the original.
@@ -556,16 +560,21 @@
 
                   frames (into #{}
                                (map #(get-in objects [% :frame-id]))
-                               selected)]
+                               selected)
+                  undo-id (uuid/next)]
+
               (rx/concat
                (->> (rx/from dup-frames)
                     (rx/map (fn [[old-id new-id]] (dwt/duplicate-thumbnail old-id new-id))))
 
                ;; Warning: This order is important for the focus mode.
-               (rx/of (dch/commit-changes changes)
-                      (select-shapes new-selected)
-                      (ptk/data-event :layout/update frames)
-                      (memorize-duplicated id-original id-duplicated))))))))))
+               (rx/of
+                (dwu/start-undo-transaction undo-id)
+                (dch/commit-changes changes)
+                (select-shapes new-selected)
+                (ptk/data-event :layout/update frames)
+                (memorize-duplicated id-original id-duplicated)
+                (dwu/commit-undo-transaction undo-id))))))))))
 
 (defn change-hover-state
   [id value]

From 407831ffd1a5bb45fa47e5bd494875444b3f88e8 Mon Sep 17 00:00:00 2001
From: Eva <eva.marco@kaleidos.net>
Date: Mon, 2 Jan 2023 09:34:19 +0100
Subject: [PATCH 14/19] :bug: Fix some visual errors

---
 .../resources/styles/main/partials/dashboard-header.scss  | 3 ++-
 .../resources/styles/main/partials/dashboard-sidebar.scss | 8 +++++++-
 frontend/src/app/main/ui/dashboard/team.cljs              | 2 +-
 3 files changed, 10 insertions(+), 3 deletions(-)

diff --git a/frontend/resources/styles/main/partials/dashboard-header.scss b/frontend/resources/styles/main/partials/dashboard-header.scss
index 9df42505b..8da45d989 100644
--- a/frontend/resources/styles/main/partials/dashboard-header.scss
+++ b/frontend/resources/styles/main/partials/dashboard-header.scss
@@ -57,9 +57,10 @@
         color: $color-gray-30;
         height: 40px;
         padding: $size-1 $size-5;
-
+        font-weight: 400;
         &:hover {
           color: $color-black;
+          text-decoration: none;
         }
       }
 
diff --git a/frontend/resources/styles/main/partials/dashboard-sidebar.scss b/frontend/resources/styles/main/partials/dashboard-sidebar.scss
index 070c5b199..0298cb704 100644
--- a/frontend/resources/styles/main/partials/dashboard-sidebar.scss
+++ b/frontend/resources/styles/main/partials/dashboard-sidebar.scss
@@ -203,7 +203,11 @@
       flex-shrink: 0;
       padding: $size-2;
       a {
+        font-weight: 400;
         width: 100%;
+        &:hover {
+          text-decoration: none;
+        }
       }
 
       svg {
@@ -279,7 +283,9 @@
       }
 
       &.current {
-        font-weight: bold;
+        a {
+          font-weight: bold;
+        }
 
         &::before {
           background-color: $color-primary;
diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs
index 9ab754741..e8dc7e20e 100644
--- a/frontend/src/app/main/ui/dashboard/team.cljs
+++ b/frontend/src/app/main/ui/dashboard/team.cljs
@@ -68,7 +68,7 @@
         [:a {:on-click go-settings} (tr "labels.settings")]]]]
      [:div.dashboard-buttons
       (if (and (or invitations-section? members-section?) (:is-admin permissions))
-        [:a.btn-primary.btn-small {:on-click invite-member :data-test "invite-member"}
+        [:a.btn-secondary.btn-small {:on-click invite-member :data-test "invite-member"}
          (tr "dashboard.invite-profile")]
         [:div.blank-space])]]))
 

From 32d39c35e4fec74f718926f240182dffb4f02a3a Mon Sep 17 00:00:00 2001
From: "alonso.torres" <alonso.torres@kaleidos.net>
Date: Fri, 30 Dec 2022 13:50:37 +0100
Subject: [PATCH 15/19] :bug: Fix problem with flipped shapes

---
 .../app/common/geom/shapes/transforms.cljc    |  8 +++----
 .../src/app/main/ui/shapes/text/svg_text.cljs | 20 +-----------------
 .../app/main/ui/workspace/shapes/text.cljs    |  2 +-
 .../shapes/text/text_edition_outline.cljs     |  2 +-
 .../main/ui/workspace/viewport/selection.cljs | 21 ++++++++++++++++---
 5 files changed, 25 insertions(+), 28 deletions(-)

diff --git a/common/src/app/common/geom/shapes/transforms.cljc b/common/src/app/common/geom/shapes/transforms.cljc
index 4383d92bb..3d9852680 100644
--- a/common/src/app/common/geom/shapes/transforms.cljc
+++ b/common/src/app/common/geom/shapes/transforms.cljc
@@ -111,10 +111,10 @@
        (cond-> (some? transform)
          (gmt/multiply transform))
 
-       (cond-> (and flip-x (not no-flip))
+       (cond-> (and flip-x no-flip)
          (gmt/scale (gpt/point -1 1)))
 
-       (cond-> (and flip-y (not no-flip))
+       (cond-> (and flip-y no-flip)
          (gmt/scale (gpt/point 1 -1)))
 
        (gmt/translate (gpt/negate shape-center)))))
@@ -126,8 +126,8 @@
   ([{:keys [transform flip-x flip-y] :as shape} {:keys [no-flip] :as params}]
    (if (and (some? shape)
             (or (some? transform)
-                (and (not no-flip) flip-x)
-                (and (not no-flip) flip-y)))
+                (and no-flip flip-x)
+                (and no-flip flip-y)))
      (dm/str (transform-matrix shape params))
      "")))
 
diff --git a/frontend/src/app/main/ui/shapes/text/svg_text.cljs b/frontend/src/app/main/ui/shapes/text/svg_text.cljs
index 6086efb9d..5e93ce855 100644
--- a/frontend/src/app/main/ui/shapes/text/svg_text.cljs
+++ b/frontend/src/app/main/ui/shapes/text/svg_text.cljs
@@ -32,23 +32,6 @@
         (d/update-when :position-data #(mapv update-color %))
         (assoc :stroke-color "#FFFFFF" :stroke-opacity 1))))
 
-(defn position-data-transform
-  [shape {:keys [x y width height]}]
-  (let [rect (gsh/make-rect x (- y height) width height)
-        center (gsh/center-rect rect)]
-    (when (or (:flip-x shape) (:flip-y shape))
-      (-> (gmt/matrix)
-          (gmt/translate center)
-
-          (cond-> (:flip-x shape)
-            (gmt/scale (gpt/point -1 1))
-
-            (:flip-y shape)
-            (gmt/scale (gpt/point 1 -1)))
-
-          (gmt/translate (gpt/negate center))
-          (dm/str)))))
-
 (mf/defc text-shape
   {::mf/wrap-props false
    ::mf/wrap [mf/memo]}
@@ -60,7 +43,7 @@
 
         {:keys [x y width height position-data]} shape
 
-        transform (gsh/transform-str shape {:no-flip true})
+        transform (gsh/transform-str shape)
 
         ;; These position attributes are not really necessary but they are convenient for for the export
         group-props (-> #js {:transform transform
@@ -96,7 +79,6 @@
                              :y (- (:y data) (:height data))
                              :textLength (:width data)
                              :lengthAdjust "spacingAndGlyphs"
-                             :transform (position-data-transform shape data)
                              :alignmentBaseline alignment-bl
                              :dominantBaseline dominant-bl
                              :style (-> #js {:fontFamily (:font-family data)
diff --git a/frontend/src/app/main/ui/workspace/shapes/text.cljs b/frontend/src/app/main/ui/workspace/shapes/text.cljs
index b51432b5b..39ae91b1c 100644
--- a/frontend/src/app/main/ui/workspace/shapes/text.cljs
+++ b/frontend/src/app/main/ui/workspace/shapes/text.cljs
@@ -39,7 +39,7 @@
       [:& text/text-shape {:shape shape}]]
 
      (when (and (debug? :text-outline) (d/not-empty? (:position-data shape)))
-       [:g {:transform (gsh/transform-str shape {:no-flip true})}
+       [:g {:transform (gsh/transform-str shape)}
         (let [bounding-box (gsht/position-data-selrect shape)]
           [:rect {
                   :x (:x bounding-box)
diff --git a/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs b/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs
index aa4e29164..d7766d517 100644
--- a/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs
+++ b/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs
@@ -28,7 +28,7 @@
                 (some? text-modifier)
                 (dwt/apply-text-modifier text-modifier))
 
-        transform (gsh/transform-str shape {:no-flip true})
+        transform (gsh/transform-str shape)
         {:keys [x y width height]} shape]
 
     [:rect.main.viewport-selrect
diff --git a/frontend/src/app/main/ui/workspace/viewport/selection.cljs b/frontend/src/app/main/ui/workspace/viewport/selection.cljs
index f4f829fc7..57fc23d8a 100644
--- a/frontend/src/app/main/ui/workspace/viewport/selection.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport/selection.cljs
@@ -272,7 +272,7 @@
         current-transform (mf/deref refs/current-transform)
 
         selrect (:selrect shape)
-        transform (gsh/transform-str shape {:no-flip true})]
+        transform (gsh/transform-str shape)]
 
     (when (not (#{:move :rotate} current-transform))
       [:g.controls {:pointer-events (if disable-handlers "none" "visible")}
@@ -297,7 +297,7 @@
         workspace-read-only? (mf/use-ctx ctx/workspace-read-only?)
 
         selrect (:selrect shape)
-        transform (gsh/transform-matrix shape {:no-flip true})
+        transform (gsh/transform-matrix shape)
 
         rotation (-> (gpt/point 1 0)
                      (gpt/transform (:transform shape))
@@ -309,7 +309,22 @@
       [:g.controls {:pointer-events (if disable-handlers "none" "visible")}
        ;; Handlers
        (for [{:keys [type position props]} (handlers-for-selection selrect shape zoom)]
-         (let [common-props {:key (dm/str (name type) "-" (name position))
+         (let [rotation
+               (cond
+                 (and (#{:top-left :bottom-right} position)
+                      (or (and (:flip-x shape) (not (:flip-y shape)))
+                          (and (:flip-y shape) (not (:flip-x shape)))))
+                 (- rotation 90)
+
+                 (and (#{:top-right :bottom-left} position)
+                      (or (and (:flip-x shape) (not (:flip-y shape)))
+                          (and (:flip-y shape) (not (:flip-x shape)))))
+                 (+ rotation 90)
+
+                 :else
+                 rotation)
+
+               common-props {:key (dm/str (name type) "-" (name position))
                              :zoom zoom
                              :position position
                              :on-rotate on-rotate

From 733b35dd538c431f882bf661e6fe0a7a9b07b15a Mon Sep 17 00:00:00 2001
From: "alonso.torres" <alonso.torres@kaleidos.net>
Date: Fri, 30 Dec 2022 16:08:27 +0100
Subject: [PATCH 16/19] :arrow_up: Updated potok dependency

---
 frontend/deps.edn                |  2 +-
 frontend/src/app/main/store.cljs | 14 ++++++++++++++
 2 files changed, 15 insertions(+), 1 deletion(-)

diff --git a/frontend/deps.edn b/frontend/deps.edn
index 606fa73c9..c158bea0e 100644
--- a/frontend/deps.edn
+++ b/frontend/deps.edn
@@ -9,7 +9,7 @@
 
   funcool/beicon {:mvn/version "2021.07.05-1"}
   funcool/okulary {:mvn/version "2022.04.11-16"}
-  funcool/potok {:mvn/version "2022.04.28-67"}
+  funcool/potok {:mvn/version "2022.12.16-71"}
   funcool/tubax {:mvn/version "2021.05.20-0"}
 
   funcool/rumext
diff --git a/frontend/src/app/main/store.cljs b/frontend/src/app/main/store.cljs
index 431ff6d3a..0c66507fc 100644
--- a/frontend/src/app/main/store.cljs
+++ b/frontend/src/app/main/store.cljs
@@ -23,8 +23,22 @@
   [type data]
   (ptk/data-event type data))
 
+;;(def debug-exclude-events
+;;  #{:app.main.data.workspace.notifications/handle-pointer-update
+;;    :app.main.data.workspace.notifications/handle-pointer-send
+;;    :app.main.data.workspace.persistence/update-persistence-status
+;;    :app.main.data.workspace.changes/update-indices
+;;    :app.main.data.websocket/send-message
+;;    :app.main.data.workspace.selection/change-hover-state})
+;; (def ^:dynamic *debug-events* false)
+
 (defonce state
   (ptk/store {:resolve ptk/resolve
+              ;;:on-event (fn [e]
+              ;;            (when (and *debug-events*
+              ;;                       (ptk/event? e)
+              ;;                       (not (debug-exclude-events (ptk/type e))))
+              ;;              (.log js/console (str "[stream]: " (ptk/repr-event e)) )))
               :on-error (fn [e] (@on-error e))}))
 
 (defonce stream

From ff9b2090cf764e1b7cc68a6fc82e43cc9e728e3c Mon Sep 17 00:00:00 2001
From: "alonso.torres" <alonso.torres@kaleidos.net>
Date: Fri, 30 Dec 2022 16:08:44 +0100
Subject: [PATCH 17/19] :bug: Fix problem with shapes moving randomly

---
 .../app/main/data/workspace/persistence.cljs  | 69 ++++++++++++-------
 1 file changed, 46 insertions(+), 23 deletions(-)

diff --git a/frontend/src/app/main/data/workspace/persistence.cljs b/frontend/src/app/main/data/workspace/persistence.cljs
index 2d8d1d8e9..c6f7d4479 100644
--- a/frontend/src/app/main/data/workspace/persistence.cljs
+++ b/frontend/src/app/main/data/workspace/persistence.cljs
@@ -30,6 +30,7 @@
 (declare persist-changes)
 (declare persist-synchronous-changes)
 (declare shapes-changes-persisted)
+(declare shapes-changes-persisted-finished)
 (declare update-persistence-status)
 
 ;; --- Persistence
@@ -42,6 +43,7 @@
       (log/debug :hint "initialize persistence")
       (let [stoper   (rx/filter (ptk/type? ::initialize-persistence) stream)
             commits  (l/atom [])
+            saving?  (l/atom false)
 
             local-file?
             #(as-> (:file-id %) event-file-id
@@ -61,13 +63,15 @@
 
             on-saving
             (fn []
+              (reset! saving? true)
               (st/emit! (update-persistence-status {:status :saving})))
 
             on-saved
             (fn []
               ;; Disable reload stoper
               (swap! st/ongoing-tasks disj :workspace-change)
-              (st/emit! (update-persistence-status {:status :saved})))]
+              (st/emit! (update-persistence-status {:status :saved}))
+              (reset! saving? false))]
 
         (rx/merge
          (->> stream
@@ -88,12 +92,15 @@
 
          (->> (rx/from-atom commits)
               (rx/filter (complement empty?))
-              (rx/sample-when (rx/merge
-                               (rx/interval 5000)
-                               (rx/filter #(= ::force-persist %) stream)
-                               (->> (rx/from-atom commits)
-                                    (rx/filter (complement empty?))
-                                    (rx/debounce 2000))))
+              (rx/sample-when
+               (->> (rx/merge
+                     (rx/interval 5000)
+                     (rx/filter #(= ::force-persist %) stream)
+                     (->> (rx/from-atom commits)
+                          (rx/filter (complement empty?))
+                          (rx/debounce 2000)))
+                    ;; Not sample while saving so there are no race conditions
+                    (rx/filter #(not @saving?))))
               (rx/tap #(reset! commits []))
               (rx/tap on-saving)
               (rx/mapcat (fn [changes]
@@ -101,9 +108,11 @@
                            ;; next persistence before this one is
                            ;; finished.
                            (rx/merge
-                            (rx/of (persist-changes file-id changes))
+                            (->> (rx/of (persist-changes file-id changes commits))
+                                 (rx/observe-on :async))
                             (->> stream
-                                 (rx/filter (ptk/type? ::changes-persisted))
+                                 ;; We wait for every change to be persisted
+                                 (rx/filter (ptk/type? ::shapes-changes-persisted-finished))
                                  (rx/take 1)
                                  (rx/tap on-saved)
                                  (rx/ignore)))))
@@ -123,7 +132,7 @@
                              (log/debug :hint "finalize persistence: synchronous save loop")))))))))
 
 (defn persist-changes
-  [file-id changes]
+  [file-id changes pending-commits]
   (log/debug :hint "persist changes" :changes (count changes))
   (us/verify ::us/uuid file-id)
   (ptk/reify ::persist-changes
@@ -150,20 +159,29 @@
                             (log/debug :hint "changes persisted" :lagged (count lagged))
                             (let [frame-updates
                                   (-> (group-by :page-id changes)
-                                      (update-vals #(into #{} (mapcat :frames) %)))]
+                                      (update-vals #(into #{} (mapcat :frames) %)))
 
-                              (rx/merge
-                               (->> (rx/from frame-updates)
-                                    (rx/mapcat (fn [[page-id frames]]
-                                                 (->> frames (map #(vector page-id %)))))
-                                    (rx/map (fn [[page-id frame-id]] (dwt/update-thumbnail (:id file) page-id frame-id))))
-                               (->> (rx/from lagged)
-                                    (rx/merge-map (fn [{:keys [changes] :as entry}]
-                                                    (rx/merge
-                                                     (rx/from
-                                                      (for [[page-id changes] (group-by :page-id changes)]
-                                                        (dch/update-indices page-id changes)))
-                                                     (rx/of (shapes-changes-persisted file-id entry))))))))))
+                                  commits
+                                  (->> @pending-commits
+                                       (map #(assoc % :revn (:revn file))))]
+
+                              (rx/concat
+                               (rx/merge
+                                (->> (rx/from frame-updates)
+                                     (rx/mapcat (fn [[page-id frames]]
+                                                  (->> frames (map #(vector page-id %)))))
+                                     (rx/map (fn [[page-id frame-id]] (dwt/update-thumbnail (:id file) page-id frame-id))))
+
+                                (->> (rx/from (concat lagged commits))
+                                     (rx/merge-map
+                                      (fn [{:keys [changes] :as entry}]
+                                        (rx/merge
+                                         (rx/from
+                                          (for [[page-id changes] (group-by :page-id changes)]
+                                            (dch/update-indices page-id changes)))
+                                         (rx/of (shapes-changes-persisted file-id entry)))))))
+
+                               (rx/of (shapes-changes-persisted-finished))))))
                (rx/catch (fn [cause]
                            (rx/concat
                             (if (= :authentication (:type cause))
@@ -171,6 +189,11 @@
                               (rx/of (rt/assign-exception cause)))
                             (rx/throw cause))))))))))
 
+;; Event to be thrown after the changes have been persisted
+(defn shapes-changes-persisted-finished
+  []
+  (ptk/reify ::shapes-changes-persisted-finished))
+
 (defn persist-synchronous-changes
   [{:keys [file-id changes]}]
   (us/verify ::us/uuid file-id)

From ade13d3bca663897cb9167c3a4084f52d5ccb6b5 Mon Sep 17 00:00:00 2001
From: "alonso.torres" <alonso.torres@kaleidos.net>
Date: Mon, 2 Jan 2023 11:13:44 +0100
Subject: [PATCH 18/19] :bug: Fix problem with auto-height text resize

---
 .../src/app/common/geom/shapes/modifiers.cljc | 56 ++++++++++------
 .../app/main/data/workspace/modifiers.cljs    | 64 +++++++++++++------
 .../src/app/main/data/workspace/texts.cljs    |  3 +-
 .../src/app/main/ui/shapes/text/svg_text.cljs |  2 -
 4 files changed, 82 insertions(+), 43 deletions(-)

diff --git a/common/src/app/common/geom/shapes/modifiers.cljc b/common/src/app/common/geom/shapes/modifiers.cljc
index 270df14d5..1966fc03f 100644
--- a/common/src/app/common/geom/shapes/modifiers.cljc
+++ b/common/src/app/common/geom/shapes/modifiers.cljc
@@ -317,33 +317,47 @@
       modif-tree)))
 
 (defn set-objects-modifiers
-  [modif-tree objects ignore-constraints snap-pixel?]
+  ([modif-tree objects ignore-constraints snap-pixel?]
+   (set-objects-modifiers nil modif-tree objects ignore-constraints snap-pixel?))
 
-  (let [objects (apply-structure-modifiers objects modif-tree)
+  ([old-modif-tree modif-tree objects ignore-constraints snap-pixel?]
+   (let [objects (-> objects
+                     (cond-> (some? old-modif-tree)
+                       (apply-structure-modifiers old-modif-tree))
+                     (apply-structure-modifiers modif-tree))
 
-        bounds (d/lazy-map (keys objects) #(dm/get-in objects [% :points]))
-        shapes-tree (resolve-tree-sequence (-> modif-tree keys set) objects)
+         bounds (d/lazy-map (keys objects) #(dm/get-in objects [% :points]))
+         bounds (cond-> bounds
+                  (some? old-modif-tree)
+                  (transform-bounds objects old-modif-tree))
 
-        ;; Calculate the input transformation and constraints
-        modif-tree (reduce #(propagate-modifiers-constraints objects bounds ignore-constraints %1 %2) modif-tree shapes-tree)
-        bounds (transform-bounds bounds objects modif-tree shapes-tree)
+         shapes-tree (resolve-tree-sequence (-> modif-tree keys set) objects)
 
-        [modif-tree-layout sizing-auto-layouts]
-        (reduce #(propagate-modifiers-layout objects bounds ignore-constraints %1 %2) [{} #{}] shapes-tree)
+         ;; Calculate the input transformation and constraints
+         modif-tree (reduce #(propagate-modifiers-constraints objects bounds ignore-constraints %1 %2) modif-tree shapes-tree)
+         bounds (transform-bounds bounds objects modif-tree shapes-tree)
 
-        modif-tree (merge-modif-tree modif-tree modif-tree-layout)
+         [modif-tree-layout sizing-auto-layouts]
+         (reduce #(propagate-modifiers-layout objects bounds ignore-constraints %1 %2) [{} #{}] shapes-tree)
 
-        ;; Calculate hug layouts positions
-        bounds (transform-bounds bounds objects modif-tree-layout shapes-tree)
+         modif-tree (merge-modif-tree modif-tree modif-tree-layout)
 
-        modif-tree
-        (-> modif-tree
-            (sizing-auto-modifiers sizing-auto-layouts objects bounds ignore-constraints))
+         ;; Calculate hug layouts positions
+         bounds (transform-bounds bounds objects modif-tree-layout shapes-tree)
 
-        modif-tree
-        (cond-> modif-tree
-          snap-pixel? (gpp/adjust-pixel-precision objects))]
+         modif-tree
+         (-> modif-tree
+             (sizing-auto-modifiers sizing-auto-layouts objects bounds ignore-constraints))
 
-    ;;#?(:cljs
-    ;;   (.log js/console ">result" (modif->js modif-tree objects)))
-    modif-tree))
+         modif-tree
+         (if old-modif-tree
+           (merge-modif-tree old-modif-tree modif-tree)
+           modif-tree)
+
+         modif-tree
+         (cond-> modif-tree
+           snap-pixel? (gpp/adjust-pixel-precision objects))]
+
+     ;;#?(:cljs
+     ;;   (.log js/console ">result" (modif->js modif-tree objects)))
+     modif-tree)))
diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs
index 808798457..ec2688421 100644
--- a/frontend/src/app/main/data/workspace/modifiers.cljs
+++ b/frontend/src/app/main/data/workspace/modifiers.cljs
@@ -190,26 +190,27 @@
                            [(get-in objects [k :name]) v]))
                     modif-tree)))
 
+(defn apply-text-modifier
+  [shape {:keys [width height]}]
+  (cond-> shape
+    (some? width)
+    (assoc :width width)
+
+    (some? height)
+    (assoc :height height)
+
+    (or (some? width) (some? height))
+    (cts/setup-rect-selrect)))
+
 (defn apply-text-modifiers
   [objects text-modifiers]
-  (letfn [(apply-text-modifier
-            [shape {:keys [width height]}]
-            (cond-> shape
-              (some? width)
-              (assoc :width width)
-
-              (some? height)
-              (assoc :height height)
-
-              (or (some? width) (some? height))
-              (cts/setup-rect-selrect)))]
-    (loop [modifiers (seq text-modifiers)
-           result objects]
-      (if (empty? modifiers)
-        result
-        (let [[id text-modifier] (first modifiers)]
-          (recur (rest modifiers)
-                 (update objects id apply-text-modifier text-modifier)))))))
+  (loop [modifiers (seq text-modifiers)
+         result objects]
+    (if (empty? modifiers)
+      result
+      (let [[id text-modifier] (first modifiers)]
+        (recur (rest modifiers)
+               (update objects id apply-text-modifier text-modifier))))))
 
 #_(defn apply-path-modifiers
   [objects path-modifiers]
@@ -242,6 +243,33 @@
        ;;(apply-path-modifiers $ (get-in state [:workspace-local :edit-path]))
        (gsh/set-objects-modifiers modif-tree $ ignore-constraints snap-pixel?)))))
 
+(defn- calculate-update-modifiers
+  [old-modif-tree state ignore-constraints ignore-snap-pixel modif-tree]
+  (let [objects
+        (wsh/lookup-page-objects state)
+
+        snap-pixel?
+        (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))
+
+        objects
+        (-> objects
+            (apply-text-modifiers (get state :workspace-text-modifier)))]
+
+    (gsh/set-objects-modifiers  old-modif-tree modif-tree objects ignore-constraints snap-pixel?)))
+
+(defn update-modifiers
+  ([modif-tree]
+   (update-modifiers modif-tree false))
+
+  ([modif-tree ignore-constraints]
+   (update-modifiers modif-tree ignore-constraints false))
+
+  ([modif-tree ignore-constraints ignore-snap-pixel]
+   (ptk/reify ::update-modifiers
+     ptk/UpdateEvent
+     (update [_ state]
+       (update state :workspace-modifiers calculate-update-modifiers state ignore-constraints ignore-snap-pixel modif-tree)))))
+
 (defn set-modifiers
   ([modif-tree]
    (set-modifiers modif-tree false))
diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs
index 7659f8b35..1d4c0098f 100644
--- a/frontend/src/app/main/data/workspace/texts.cljs
+++ b/frontend/src/app/main/data/workspace/texts.cljs
@@ -408,8 +408,7 @@
                       (not (mth/close? (:height props) current-height))))
 
            (let [modif-tree (dwm/create-modif-tree [id] (ctm/reflow-modifiers))]
-             (->> (rx/of (dwm/set-modifiers modif-tree))
-                  (rx/observe-on :async)))
+             (rx/of (dwm/update-modifiers modif-tree)))
            (rx/empty)))))))
 
 (defn clean-text-modifier
diff --git a/frontend/src/app/main/ui/shapes/text/svg_text.cljs b/frontend/src/app/main/ui/shapes/text/svg_text.cljs
index 5e93ce855..64edf8503 100644
--- a/frontend/src/app/main/ui/shapes/text/svg_text.cljs
+++ b/frontend/src/app/main/ui/shapes/text/svg_text.cljs
@@ -8,8 +8,6 @@
   (:require
    [app.common.data :as d]
    [app.common.data.macros :as dm]
-   [app.common.geom.matrix :as gmt]
-   [app.common.geom.point :as gpt]
    [app.common.geom.shapes :as gsh]
    [app.config :as cf]
    [app.main.ui.context :as muc]

From 3ce1540331e82c53da2381d4a14368948b6a434e Mon Sep 17 00:00:00 2001
From: Pablo Alba <pablo.alba@kaleidos.net>
Date: Mon, 2 Jan 2023 11:20:54 +0100
Subject: [PATCH 19/19] :bug: Fix export/import svg + json format isn't working

---
 backend/src/app/rpc/commands/files/temp.clj | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/backend/src/app/rpc/commands/files/temp.clj b/backend/src/app/rpc/commands/files/temp.clj
index 74468f486..0bc2c1c87 100644
--- a/backend/src/app/rpc/commands/files/temp.clj
+++ b/backend/src/app/rpc/commands/files/temp.clj
@@ -40,7 +40,7 @@
   [{:keys [pool] :as cfg} {:keys [::rpc/profile-id project-id] :as params}]
   (db/with-atomic [conn pool]
     (proj/check-edition-permissions! conn profile-id project-id)
-    (files.create/create-file conn (assoc params :deleted-at (dt/in-future {:days 1})))))
+    (files.create/create-file conn (assoc params :profile-id profile-id :deleted-at (dt/in-future {:days 1})))))
 
 ;; --- MUTATION COMMAND: update-temp-file