0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-03-20 19:51:23 -05:00

♻️ Refactor comments RPC methods and add tests

This commit is contained in:
Andrey Antukh 2022-12-30 00:27:05 +01:00
parent 73a3e0c0ae
commit 27451b9796
10 changed files with 571 additions and 233 deletions

View file

@ -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

View file

@ -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 [_ _]

View file

@ -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)))

View file

@ -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

View file

@ -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!)))

View file

@ -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}]

View file

@ -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))))))
)))

View file

@ -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

View file

@ -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)

View file

@ -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