Implement version locking functionality for file snapshots

Signed-off-by: Laurie Crean <lmcrean@gmail.com>
This commit is contained in:
Laurie Crean
2025-07-26 11:39:12 +01:00
committed by Andrey Antukh
parent 1892fa6782
commit 0b47a366ab
9 changed files with 286 additions and 20 deletions

View File

@@ -441,7 +441,10 @@
:fn (mg/resource "app/migrations/sql/0139-mod-file-change-table.sql")}
{:name "0140-mod-file-change-table.sql"
:fn (mg/resource "app/migrations/sql/0140-mod-file-change-table.sql")}])
:fn (mg/resource "app/migrations/sql/0140-mod-file-change-table.sql")}
{:name "0140-add-locked-by-column-to-file-change-table"
:fn (mg/resource "app/migrations/sql/0140-add-locked-by-column-to-file-change-table.sql")}])
(defn apply-migrations!
[pool name migrations]

View File

@@ -0,0 +1,11 @@
-- Add locked_by column to file_change table for version locking feature
-- This allows users to lock their own saved versions to prevent deletion by others
ALTER TABLE file_change
ADD COLUMN locked_by uuid NULL REFERENCES profile(id) ON DELETE SET NULL DEFERRABLE;
-- Create index for locked versions queries
CREATE INDEX file_change__locked_by__idx ON file_change (locked_by) WHERE locked_by IS NOT NULL;
-- Add comment for documentation
COMMENT ON COLUMN file_change.locked_by IS 'Profile ID of user who has locked this version. Only the creator can lock/unlock their own versions. Locked versions cannot be deleted by others.';

View File

@@ -38,7 +38,7 @@
(def sql:get-file-snapshots
"WITH changes AS (
SELECT id, label, revn, created_at, created_by, profile_id
SELECT id, label, revn, created_at, created_by, profile_id, locked_by
FROM file_change
WHERE file_id = ?
AND data IS NOT NULL
@@ -284,7 +284,7 @@
[conn id]
(db/get conn :file-change
{:id id}
{::sql/columns [:id :file-id :created-by :deleted-at]
{::sql/columns [:id :file-id :created-by :deleted-at :profile-id :locked-by]
::db/for-update true}))
(sv/defmethod ::update-file-snapshot
@@ -324,4 +324,111 @@
:snapshot-id id
:profile-id profile-id))
;; Check if version is locked by someone else
(when (and (:locked-by snapshot)
(not= (:locked-by snapshot) profile-id))
(ex/raise :type :validation
:code :snapshot-is-locked
:hint "Cannot delete a locked version"
:snapshot-id id
:profile-id profile-id
:locked-by (:locked-by snapshot)))
(delete-file-snapshot! conn id)))))
;;; Lock/unlock version endpoints
(def ^:private schema:lock-file-snapshot
[:map {:title "lock-file-snapshot"}
[:id ::sm/uuid]])
(defn- lock-file-snapshot!
[conn snapshot-id profile-id]
(db/update! conn :file-change
{:locked-by profile-id}
{:id snapshot-id}
{::db/return-keys false})
nil)
(sv/defmethod ::lock-file-snapshot
{::doc/added "1.20"
::sm/params schema:lock-file-snapshot}
[cfg {:keys [::rpc/profile-id id]}]
(db/tx-run! cfg
(fn [{:keys [::db/conn]}]
(let [snapshot (get-snapshot conn id)]
(files/check-edition-permissions! conn profile-id (:file-id snapshot))
(when (not= (:created-by snapshot) "user")
(ex/raise :type :validation
:code :system-snapshots-cant-be-locked
:hint "Only user-created versions can be locked"
:snapshot-id id
:profile-id profile-id))
;; Only the creator can lock their own version
(when (not= (:profile-id snapshot) profile-id)
(ex/raise :type :validation
:code :only-creator-can-lock
:hint "Only the version creator can lock it"
:snapshot-id id
:profile-id profile-id
:creator-id (:profile-id snapshot)))
;; Check if already locked
(when (:locked-by snapshot)
(ex/raise :type :validation
:code :snapshot-already-locked
:hint "Version is already locked"
:snapshot-id id
:profile-id profile-id
:locked-by (:locked-by snapshot)))
(lock-file-snapshot! conn id profile-id)))))
(def ^:private schema:unlock-file-snapshot
[:map {:title "unlock-file-snapshot"}
[:id ::sm/uuid]])
(defn- unlock-file-snapshot!
[conn snapshot-id]
(db/update! conn :file-change
{:locked-by nil}
{:id snapshot-id}
{::db/return-keys false})
nil)
(sv/defmethod ::unlock-file-snapshot
{::doc/added "1.20"
::sm/params schema:unlock-file-snapshot}
[cfg {:keys [::rpc/profile-id id]}]
(db/tx-run! cfg
(fn [{:keys [::db/conn]}]
(let [snapshot (get-snapshot conn id)]
(files/check-edition-permissions! conn profile-id (:file-id snapshot))
(when (not= (:created-by snapshot) "user")
(ex/raise :type :validation
:code :system-snapshots-cant-be-unlocked
:hint "Only user-created versions can be unlocked"
:snapshot-id id
:profile-id profile-id))
;; Only the creator can unlock their own version
(when (not= (:profile-id snapshot) profile-id)
(ex/raise :type :validation
:code :only-creator-can-unlock
:hint "Only the version creator can unlock it"
:snapshot-id id
:profile-id profile-id
:creator-id (:profile-id snapshot)))
;; Check if not locked
(when (not (:locked-by snapshot))
(ex/raise :type :validation
:code :snapshot-not-locked
:hint "Version is not locked"
:snapshot-id id
:profile-id profile-id))
(unlock-file-snapshot! conn id)))))

View File

@@ -148,6 +148,23 @@
(fetch-versions)
(ptk/event ::ev/event {::ev/name "pin-version"})))))))))
(defn lock-version
[id]
(assert (uuid? id) "expected valid uuid for `id`")
(ptk/reify ::lock-version
ptk/WatchEvent
(watch [_ _ _]
(->> (rp/cmd! :lock-file-snapshot {:id id})
(rx/map fetch-versions)))))
(defn unlock-version
[id]
(assert (uuid? id) "expected valid uuid for `id`")
(ptk/reify ::unlock-version
ptk/WatchEvent
(watch [_ _ _]
(->> (rp/cmd! :unlock-file-snapshot {:id id})
(rx/map fetch-versions)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PLUGINS SPECIFIC EVENTS

View File

@@ -148,6 +148,38 @@
(= code :vern-conflict)
(st/emit! (ptk/event ::dw/reload-current-file))
(= code :snapshot-is-locked)
(let [message (tr "errors.version-locked")]
(st/async-emit!
(ntf/show {:content message
:type :toast
:level :error
:timeout 3000})))
(= code :only-creator-can-lock)
(let [message (tr "errors.only-creator-can-lock")]
(st/async-emit!
(ntf/show {:content message
:type :toast
:level :error
:timeout 3000})))
(= code :only-creator-can-unlock)
(let [message (tr "errors.only-creator-can-unlock")]
(st/async-emit!
(ntf/show {:content message
:type :toast
:level :error
:timeout 3000})))
(= code :snapshot-already-locked)
(let [message (tr "errors.version-already-locked")]
(st/async-emit!
(ntf/show {:content message
:type :toast
:level :error
:timeout 3000})))
:else
(st/async-emit! (rt/assign-exception error))))

View File

@@ -11,6 +11,7 @@
[app.common.time :as ct]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.ds.product.avatar :refer [avatar*]]
@@ -24,6 +25,7 @@
[:class {:optional true} :string]
[:active {:optional true} :boolean]
[:editing {:optional true} :boolean]
[:locked {:optional true} :boolean]
[:user
[:map
[:name {:optional true} [:maybe :string]]
@@ -38,7 +40,7 @@
(mf/defc user-milestone*
{::mf/schema schema:milestone}
[{:keys [class active editing user label date
[{:keys [class active editing locked user label date
onOpenMenu onFocusInput onBlurInput onKeyDownInput] :rest props}]
(let [class' (stl/css-case :milestone true
:is-selected active)
@@ -64,10 +66,10 @@
:on-focus onFocusInput
:on-blur onBlurInput
:on-key-down onKeyDownInput}]
[:> text* {:as "span"
:typography t/body-small
:class (stl/css :name)}
label])
[:div {:class (stl/css :name-wrapper)}
[:> text* {:as "span" :typography t/body-small :class (stl/css :name)} label]
(when locked
[:> i/icon* {:icon-id i/lock :class (stl/css :lock-icon)}])])
[:*
[:time {:date-time (ct/format-inst date :iso)

View File

@@ -42,11 +42,23 @@
justify-self: flex-end;
}
.name-wrapper {
display: flex;
align-items: baseline;
}
.name {
grid-area: name;
color: var(--color-foreground-primary);
}
.lock-icon {
margin-left: 8px;
transform: scale(0.8);
color: var(--color-foreground-secondary);
align-self: anchor-center;
}
.date {
@include t.use-typography("body-small");
grid-area: content;

View File

@@ -75,7 +75,7 @@
(reverse)))
(mf/defc version-entry
[{:keys [entry profile on-restore-version on-delete-version on-rename-version editing?]}]
[{:keys [entry profile current-profile on-restore-version on-delete-version on-rename-version on-lock-version on-unlock-version editing?]}]
(let [show-menu? (mf/use-state false)
handle-open-menu
@@ -108,6 +108,20 @@
(when on-delete-version
(on-delete-version (:id entry)))))
handle-lock-version
(mf/use-callback
(mf/deps entry on-lock-version)
(fn []
(when on-lock-version
(on-lock-version (:id entry)))))
handle-unlock-version
(mf/use-callback
(mf/deps entry on-unlock-version)
(fn []
(when on-unlock-version
(on-unlock-version (:id entry)))))
handle-name-input-focus
(mf/use-fn
(fn [event]
@@ -141,22 +155,44 @@
:color (:color profile)}
:editing editing?
:date (:created-at entry)
:locked (boolean (:locked-by entry))
:onOpenMenu handle-open-menu
:onFocusInput handle-name-input-focus
:onBlurInput handle-name-input-blur
:onKeyDownInput handle-name-input-key-down}]
[:& dropdown {:show @show-menu? :on-close handle-close-menu}
[:ul {:class (stl/css :version-options-dropdown)}
[:li {:class (stl/css :menu-option)
:role "button"
:on-click handle-rename-version} (tr "labels.rename")]
[:li {:class (stl/css :menu-option)
:role "button"
:on-click handle-restore-version} (tr "labels.restore")]
[:li {:class (stl/css :menu-option)
:role "button"
:on-click handle-delete-version} (tr "labels.delete")]]]]))
(let [current-user-id (:id current-profile)
version-creator-id (:profile-id entry)
locked-by-id (:locked-by entry)
is-version-creator? (= current-user-id version-creator-id)
is-locked? (some? locked-by-id)
is-locked-by-me? (= current-user-id locked-by-id)
can-rename? is-version-creator?
can-lock? (and is-version-creator? (not is-locked?))
can-unlock? (and is-version-creator? is-locked-by-me?)
can-delete? (or (not is-locked?) (and is-locked? is-locked-by-me?))]
[:ul {:class (stl/css :version-options-dropdown)}
(when can-rename?
[:li {:class (stl/css :menu-option)
:role "button"
:on-click handle-rename-version} (tr "labels.rename")])
[:li {:class (stl/css :menu-option)
:role "button"
:on-click handle-restore-version} (tr "labels.restore")]
(cond
can-unlock?
[:li {:class (stl/css :menu-option)
:role "button"
:on-click handle-unlock-version} (tr "labels.unlock")]
can-lock?
[:li {:class (stl/css :menu-option)
:role "button"
:on-click handle-lock-version} (tr "labels.lock")])
(when can-delete?
[:li {:class (stl/css :menu-option)
:role "button"
:on-click handle-delete-version} (tr "labels.delete")])])]]))
(mf/defc snapshot-entry
[{:keys [index is-expanded entry on-toggle-expand on-pin-snapshot on-restore-snapshot]}]
@@ -310,6 +346,16 @@
(fn [id]
(st/emit! (dwv/pin-version id))))
handle-lock-version
(mf/use-fn
(fn [id]
(st/emit! (dwv/lock-version id))))
handle-unlock-version
(mf/use-fn
(fn [id]
(st/emit! (dwv/unlock-version id))))
handle-change-filter
(mf/use-fn
(fn [filter]
@@ -367,9 +413,12 @@
:entry entry
:editing? (= (:id entry) editing)
:profile (get profiles (:profile-id entry))
:current-profile profile
:on-rename-version handle-rename-version
:on-restore-version handle-restore-version-pinned
:on-delete-version handle-delete-version}]
:on-delete-version handle-delete-version
:on-lock-version handle-lock-version
:on-unlock-version handle-unlock-version}]
:snapshot
[:& snapshot-entry {:key idx-entry

View File

@@ -1380,6 +1380,22 @@ msgstr "Password should at least be 8 characters"
msgid "errors.paste-data-validation"
msgstr "Invalid data in clipboard"
#: src/app/main/errors.cljs:152
msgid "errors.version-locked"
msgstr "This version is locked and cannot be deleted by others"
#: src/app/main/errors.cljs:160
msgid "errors.only-creator-can-lock"
msgstr "Only the version creator can lock it"
#: src/app/main/errors.cljs:168
msgid "errors.only-creator-can-unlock"
msgstr "Only the version creator can unlock it"
#: src/app/main/errors.cljs:176
msgid "errors.version-already-locked"
msgstr "This version is already locked"
#: src/app/main/data/auth.cljs:312, src/app/main/ui/auth/login.cljs:103, src/app/main/ui/auth/login.cljs:111
msgid "errors.profile-blocked"
msgstr "The profile is blocked"
@@ -2133,6 +2149,10 @@ msgstr "Libraries & Templates"
msgid "labels.loading"
msgstr "Loading…"
#: src/app/main/ui/workspace/sidebar/versions.cljs:179
msgid "labels.lock"
msgstr "Lock"
#: src/app/main/ui/viewer/header.cljs:208
msgid "labels.log-or-sign"
msgstr "Log in or sign up"
@@ -2453,6 +2473,10 @@ msgstr "Tutorials"
msgid "labels.unknown-error"
msgstr "Unknown error"
#: src/app/main/ui/workspace/sidebar/versions.cljs:176
msgid "labels.unlock"
msgstr "Unlock"
#: src/app/main/ui/dashboard/file_menu.cljs:264
msgid "labels.unpublish-multi-files"
msgstr "Unpublish %s files"
@@ -7881,6 +7905,15 @@ msgstr "History"
msgid "workspace.versions.version-menu"
msgstr "Open version menu"
msgid "workspace.versions.locked-by-other"
msgstr "This version is locked by %s and cannot be modified"
msgid "workspace.versions.locked-by-you"
msgstr "This version is locked by you"
msgid "workspace.versions.tooltip.locked-version"
msgstr "Locked version - only the creator can modify it"
#: src/app/main/ui/workspace/sidebar/versions.cljs:372
#, markdown
msgid "workspace.versions.warning.subtext"