penpot/backend/src/app/rpc/quotes.clj
Andrey Antukh ad7e035b63
Add indirection for upload-chunks storage via new table (#11651)
*  Add upload_session_chunk indirection for chunked uploads

Chunks now live in the upload_session_chunk table with non-deleting
foreign keys to storage_object and upload_session, instead of tempfile
objects with session metadata. Reads go through a JOIN, so chunk state
never scans storage_object.

Uploads validate the live session, reject duplicate indexes, and store
objects in the new upload-session bucket without extra metadata.
Assemble removes mappings and marks the session consumed; objects-gc
procedurally purges consumed and stalled sessions, touching referenced
objects first. Touched-gc and deleted-gc handle the new bucket, and
upload-session-gc is removed.

Closes #11644

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Fix quota, give-up and coverage for session chunks

Exclude consumed sessions from the sessions-per-profile quota so
finished uploads free their slot at once. Remove chunk mappings before
the gc-deleted give-up delete to respect the NO ACTION keys. Catch
java.sql.SQLException for duplicate chunks. Cover the profile-owned
session purge and the UNIQUE race backstop with tests.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Align chunked upload tests with upload_session_chunk

Drop the duplicate-index tests written against metadata-backed
chunks; the UNIQUE mapping makes those cases unrepresentable and
the new tests cover them. Rewrite the rejected-duplicate tests to
expect :validation/:chunk-already-exists and assert against the
upload_session_chunk table, and scope the chunk-too-large "nothing
stored" check to the mapping table.

AI-assisted-by: muse-spark-1.3-contributor

* ♻️ Use NO ACTION DEFERRABLE session FKs in single migration

Fold the profile FK change into 0154 so the feature ships one
migration. All three upload session FKs use ON DELETE NO ACTION
DEFERRABLE: identical to RESTRICT in normal operation, but deferrable
for tooling that relies on SET CONSTRAINTS ALL DEFERRED. Extend the
RESTRICT test to the direct profile delete.

AI-assisted-by: muse-spark-1.3-contributor

* ♻️ Reserve chunk slot before writing blob in upload-chunk

Make object_id nullable and insert the mapping with NULL inside the
session-locking transaction, then write the blob outside it and link
it with a conditional update. A failed write removes the mapping and
reraises; a mid-flight death leaves a NULL row and the client starts
a new session.

AI-assisted-by: muse-spark-1.3-contributor

* 🔥 Remove redundant session_id index on upload_session_chunk

The UNIQUE(session_id, chunk_index) btree already serves
session_id-only lookups and the session FK check through its
leftmost column, so the standalone index only taxed the
per-chunk INSERT path. Verified with EXPLAIN on an equivalent
table shape.

AI-assisted-by: muse-spark-1.3-contributor

*  Merge chunk touch and delete into single RETURNING query

Replace the SELECT-then-DELETE round-trip in
delete-upload-sessions! with DELETE ... RETURNING object_id,
touching each returned object. Same semantics, one less query
per purged session. Follows the RETURNING pattern already used
in file-gc.

AI-assisted-by: muse-spark-1.3-contributor

* ♻️ Let objects-gc own chunk mapping deletion

Assemble-chunks now only marks the session as consumed; the
chunk mappings stay until objects-gc purges them (touching the
chunk objects first), leaving a single procedural deletion
path for consumed, stalled and profile-purge sessions.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Fix font-deletion GC expectations for chunk objects

Update final storage-gc-touched counts to include the two
chunk objects touched by objects-gc when purging consumed
upload sessions (8/5/5 instead of 6/3/3).

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Release chunk reservation when the link UPDATE fails

Review feedback on #11651: the link UPDATE in upload-chunk could
leave a NULL reservation behind, blocking retries of the same
index with :chunk-already-exists. Remove the reservation when
the link fails so the client can retry in the same session;
the orphaned blob stays touched for touched-gc. Also realign
the process-bucket! cond branches in gc-touched.

Tests: chunked-upload-link-failure-releases-slot and
chunked-upload-null-reservation-blocks-retry.

AI-assisted-by: muse-spark-1.3-contributor
2026-09-15 17:26:43 +02:00

630 lines
23 KiB
Clojure

;; 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 SUBSIDIARY SL
(ns app.rpc.quotes
"Penpot resource usage quotes."
(:require
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.config :as cf]
[app.db :as db]
[app.worker :as wrk]
[cuerdas.core :as str]))
(defmulti check-quote ::id)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PUBLIC API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:quote
[:map {:title "Quote"}
[::team-id {:optional true} ::sm/uuid]
[::project-id {:optional true} ::sm/uuid]
[::file-id {:optional true} ::sm/uuid]
[::incr {:optional true} [::sm/int {:min 0}]]
[::id :keyword]
[::profile-id ::sm/uuid]])
(def valid-quote?
(sm/lazy-validator schema:quote))
(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
[cfg quote]
(let [quote (merge cfg quote)
id (::id quote)]
(when-not (valid-quote? quote)
(ex/raise :type :internal
:code :invalid-quote-definition
:hint "found invalid data for quote schema"
:quote (name id)))
(-> (assoc quote ::target (name id))
(check-quote))))
(defn check!
([cfg]
(when (contains? cf/flags :quotes)
(when @enabled
(db/run! cfg check {}))))
([cfg & others]
(when (contains? cf/flags :quotes)
(when @enabled
(db/run! cfg (fn [cfg]
(run! (partial check cfg) others)))))))
(defn- send-notification!
[{:keys [::db/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"
"- 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! {::db/conn conn
::wrk/task :sendmail
::wrk/delay (ct/duration "30s")
::wrk/max-retries 4
::wrk/priority 200
::wrk/dedupe true
::wrk/label "quotes-notification"
::wrk/params {:to (vec admins)
:subject subject
:body content}}))))
(defn- generic-check!
[{:keys [::db/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 (:total (db/exec-one! conn count-sql))]
(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 schema:teams-per-profile
[:map [::profile-id ::sm/uuid]])
(def ^:private valid-teams-per-profile-quote?
(sm/lazy-validator schema:teams-per-profile))
(def ^:private sql:get-teams-per-profile
"SELECT count(*) AS total
FROM team_profile_rel
WHERE profile_id = ?")
(defmethod check-quote ::teams-per-profile
[{:keys [::profile-id ::target] :as quote}]
(assert (valid-teams-per-profile-quote? quote) "invalid quote parameters")
(-> 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: ACCESS-TOKENS-PER-PROFILE
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:access-tokens-per-profile
[:map [::profile-id ::sm/uuid]])
(def ^:private valid-access-tokens-per-profile-quote?
(sm/lazy-validator schema:access-tokens-per-profile))
(def ^:private sql:get-access-tokens-per-profile
"SELECT count(*) AS total
FROM access_token
WHERE profile_id = ?")
(defmethod check-quote ::access-tokens-per-profile
[{:keys [::profile-id ::target] :as quote}]
(assert (valid-access-tokens-per-profile-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-access-tokens-per-profile Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-1 target profile-id])
(assoc ::count-sql [sql:get-access-tokens-per-profile profile-id])
(generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: PROJECTS-PER-TEAM
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:projects-per-team
[:map
[::profile-id ::sm/uuid]
[::team-id ::sm/uuid]])
(def ^:private valid-projects-per-team-quote?
(sm/lazy-validator schema: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")
(defmethod check-quote ::projects-per-team
[{:keys [::profile-id ::team-id ::target] :as quote}]
(assert (valid-projects-per-team-quote? quote) "invalid quote parameters")
(-> 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 schema:font-variants-per-team
[:map
[::profile-id ::sm/uuid]
[::team-id ::sm/uuid]])
(def ^:private valid-font-variant-per-team-quote?
(sm/lazy-validator schema: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 = ?")
(defmethod check-quote ::font-variants-per-team
[{:keys [::profile-id ::team-id ::target] :as quote}]
(assert (valid-font-variant-per-team-quote? quote) "invalid quote parameters")
(-> 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 schema:invitations-per-team
[:map
[::profile-id ::sm/uuid]
[::team-id ::sm/uuid]])
(def ^:private valid-invitations-per-team-quote?
(sm/lazy-validator schema:invitations-per-team))
(def ^:private sql:get-invitations-per-team
"SELECT count(*) AS total
FROM team_invitation
WHERE team_id = ?")
(defmethod check-quote ::invitations-per-team
[{:keys [::profile-id ::team-id ::target] :as quote}]
(assert (valid-invitations-per-team-quote? quote) "invalid quote parameters")
(-> 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 schema:profiles-per-team
[:map
[::profile-id ::sm/uuid]
[::team-id ::sm/uuid]])
(def ^:private valid-profiles-per-team-quote?
(sm/lazy-validator schema: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.
(defmethod check-quote ::profiles-per-team
[{:keys [::profile-id ::team-id ::target] :as quote}]
(assert (valid-profiles-per-team-quote? quote) "invalid quote parameters")
(-> 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 schema:files-per-project
[:map
[::profile-id ::sm/uuid]
[::project-id ::sm/uuid]
[::team-id ::sm/uuid]])
(def ^:private valid-files-per-project-quote?
(sm/lazy-validator schema: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")
(defmethod check-quote ::files-per-project
[{:keys [::profile-id ::project-id ::team-id ::target] :as quote}]
(assert (valid-files-per-project-quote? quote) "invalid quote parameters")
(-> 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 schema:comment-threads-per-file
[:map
[::profile-id ::sm/uuid]
[::project-id ::sm/uuid]
[::team-id ::sm/uuid]])
(def ^:private valid-comment-threads-per-file-quote?
(sm/lazy-validator schema: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 = ?")
(defmethod check-quote ::comment-threads-per-file
[{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}]
(assert (valid-comment-threads-per-file-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-comment-threads-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-comment-threads-per-file file-id])
(generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: COMMENTS-PER-FILE
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:comments-per-file
[:map
[::profile-id ::sm/uuid]
[::project-id ::sm/uuid]
[::team-id ::sm/uuid]])
(def ^:private valid-comments-per-file-quote?
(sm/lazy-validator schema: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 = ?")
(defmethod check-quote ::comments-per-file
[{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}]
(assert (valid-comments-per-file-quote? quote) "invalid quote parameters")
(-> 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: SNAPSHOTS-PER-FILE
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:snapshots-per-file
[:map
[::profile-id ::sm/uuid]
[::project-id ::sm/uuid]
[::team-id ::sm/uuid]
[::file-id ::sm/uuid]])
(def ^:private valid-snapshots-per-file-quote?
(sm/lazy-validator schema:snapshots-per-file))
(def ^:private sql:get-snapshots-per-file
"SELECT count(*) AS total
FROM file_change AS fc
WHERE fc.file_id = ?
AND fc.created_by = 'user'
AND fc.deleted_at IS NULL
AND fc.data IS NOT NULL")
(defmethod check-quote ::snapshots-per-file
[{:keys [::profile-id ::file-id ::team-id ::project-id ::target] :as quote}]
(assert (valid-snapshots-per-file-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-snapshots-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-snapshots-per-file file-id])
(generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: SNAPSHOTS-PER-TEAM
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:snapshots-per-team
[:map
[::profile-id ::sm/uuid]
[::team-id ::sm/uuid]])
(def ^:private valid-snapshots-per-team-quote?
(sm/lazy-validator schema:snapshots-per-team))
(def ^:private sql:get-snapshots-per-team
"SELECT count(*) AS total
FROM file_change AS fc
JOIN file AS f ON (f.id = fc.file_id)
JOIN project AS p ON (p.id = f.project_id)
WHERE p.team_id = ?
AND fc.created_by = 'user'
AND fc.deleted_at IS NULL
AND fc.data IS NOT NULL")
(defmethod check-quote ::snapshots-per-team
[{:keys [::profile-id ::team-id ::target] :as quote}]
(assert (valid-snapshots-per-team-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-snapshots-per-team Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
(assoc ::count-sql [sql:get-snapshots-per-team team-id])
(generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: TEAM-ACCESS-REQUESTS-PER-TEAM
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:team-access-requests-per-team
[:map
[::profile-id ::sm/uuid]
[::team-id ::sm/uuid]])
(def ^:private valid-team-access-requests-per-team-quote?
(sm/lazy-validator schema:team-access-requests-per-team))
(def ^:private sql:get-team-access-requests-per-team
"SELECT count(*) AS total
FROM team_access_request AS tar
WHERE tar.team_id = ?")
(defmethod check-quote ::team-access-requests-per-team
[{:keys [::profile-id ::team-id ::target] :as quote}]
(assert (valid-team-access-requests-per-team-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-team-access-requests-per-team Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
(assoc ::count-sql [sql:get-team-access-requests-per-team team-id])
(generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: TEAM-ACCESS-REQUESTS-PER-REQUESTER
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:team-access-requests-per-requester
[:map
[::profile-id ::sm/uuid]])
(def ^:private valid-team-access-requests-per-requester-quote?
(sm/lazy-validator schema:team-access-requests-per-requester))
(def ^:private sql:get-team-access-requests-per-requester
"SELECT count(*) AS total
FROM team_access_request AS tar
WHERE tar.requester_id = ?")
(defmethod check-quote ::team-access-requests-per-requester
[{:keys [::profile-id ::target] :as quote}]
(assert (valid-team-access-requests-per-requester-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-team-access-requests-per-requester Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-1 target profile-id])
(assoc ::count-sql [sql:get-team-access-requests-per-requester profile-id])
(generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: UPLOAD-SESSIONS-PER-PROFILE
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:upload-sessions-per-profile
[:map [::profile-id ::sm/uuid]])
(def ^:private valid-upload-sessions-per-profile-quote?
(sm/lazy-validator schema:upload-sessions-per-profile))
(def ^:private sql:get-upload-sessions-per-profile
"SELECT count(*) AS total
FROM upload_session
WHERE profile_id = ?
AND deleted_at IS NULL")
(defmethod check-quote ::upload-sessions-per-profile
[{:keys [::profile-id ::target] :as quote}]
(assert (valid-upload-sessions-per-profile-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-upload-sessions-per-profile Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-1 target profile-id])
(assoc ::count-sql [sql:get-upload-sessions-per-profile profile-id])
(generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: MEDIA-STORAGE-BYTES-PER-TEAM
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:media-storage-bytes-per-team
[:map
[::profile-id ::sm/uuid]
[::team-id ::sm/uuid]])
(def ^:private valid-media-storage-bytes-per-team-quote?
(sm/lazy-validator schema:media-storage-bytes-per-team))
(def ^:private sql:get-media-storage-bytes-per-team
"SELECT COALESCE(SUM(so.size), 0) AS total
FROM (
SELECT fmo.media_id AS so_id
FROM file_media_object AS fmo
JOIN file AS f ON (f.id = fmo.file_id)
JOIN project AS p ON (p.id = f.project_id)
WHERE p.team_id = ?
AND fmo.deleted_at IS NULL
AND f.deleted_at IS NULL
UNION
SELECT fmo.thumbnail_id AS so_id
FROM file_media_object AS fmo
JOIN file AS f ON (f.id = fmo.file_id)
JOIN project AS p ON (p.id = f.project_id)
WHERE p.team_id = ?
AND fmo.thumbnail_id IS NOT NULL
AND fmo.deleted_at IS NULL
AND f.deleted_at IS NULL
UNION
SELECT v.otf_file_id AS so_id
FROM team_font_variant AS v
WHERE v.team_id = ?
AND v.otf_file_id IS NOT NULL
AND v.deleted_at IS NULL
UNION
SELECT v.ttf_file_id AS so_id
FROM team_font_variant AS v
WHERE v.team_id = ?
AND v.ttf_file_id IS NOT NULL
AND v.deleted_at IS NULL
UNION
SELECT v.woff1_file_id AS so_id
FROM team_font_variant AS v
WHERE v.team_id = ?
AND v.woff1_file_id IS NOT NULL
AND v.deleted_at IS NULL
UNION
SELECT v.woff2_file_id AS so_id
FROM team_font_variant AS v
WHERE v.team_id = ?
AND v.woff2_file_id IS NOT NULL
AND v.deleted_at IS NULL
) AS refs
JOIN storage_object AS so ON (so.id = refs.so_id)
WHERE so.deleted_at IS NULL")
(defmethod check-quote ::media-storage-bytes-per-team
[{:keys [::profile-id ::team-id ::target] :as quote}]
(assert (valid-media-storage-bytes-per-team-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-media-storage-bytes-per-team
(* 20 1024 1024 1024)))
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
(assoc ::count-sql [sql:get-media-storage-bytes-per-team
team-id team-id team-id team-id team-id team-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"))