Merge pull request #6314 from penpot/niwinz-subscriptions-internal-api

 Add prepl api for subscriptions
This commit is contained in:
Andrey Antukh 2025-04-28 10:34:29 +02:00 committed by GitHub
commit 1f0644ea91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 316 additions and 68 deletions

View file

@ -35,40 +35,35 @@ def get_prepl_conninfo():
return host, port return host, port
def send_eval(expr): def send(data):
host, port = get_prepl_conninfo() host, port = get_prepl_conninfo()
with socket.create_connection((host, port)) as s:
f = s.makefile(mode="rw")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: json.dump(data, f)
s.connect((host, port)) f.write("\n")
s.send(expr.encode("utf-8")) f.flush()
s.send(b":repl/quit\n\n")
with s.makefile() as f:
while True: while True:
line = f.readline() line = f.readline()
result = json.loads(line) result = json.loads(line)
tag = result.get("tag", None) tag = result.get("tag", None)
if tag == "ret": if tag == "ret":
return result.get("val", None), result.get("exception", None) return result.get("val", None), result.get("err", None)
elif tag == "out": elif tag == "out":
print(result.get("val"), end="") print(result.get("val"), end="")
else: else:
raise RuntimeError("unexpected response from PREPL") raise RuntimeError("unexpected response from PREPL")
def encode(val): def print_error(error):
return json.dumps(json.dumps(val)) print("ERR:", error["hint"])
def print_error(res):
for error in res["via"]:
print("ERR:", error["message"])
break
def run_cmd(params): def run_cmd(params):
try: try:
expr = "(app.srepl.cli/exec {})".format(encode(params)) res, err = send(params)
res, failed = send_eval(expr) if err:
if failed: print_error(err)
print_error(res)
sys.exit(-1) sys.exit(-1)
return res return res
@ -96,7 +91,7 @@ def update_profile(email, fullname, password, is_active):
"email": email, "email": email,
"fullname": fullname, "fullname": fullname,
"password": password, "password": password,
"is_active": is_active "isActive": is_active
} }
} }
@ -138,7 +133,7 @@ def derive_password(password):
params = { params = {
"cmd": "derive-password", "cmd": "derive-password",
"params": { "params": {
"password": password, "password": password
} }
} }

View file

@ -76,9 +76,10 @@
(perms/make-check-fn has-read-permissions?)) (perms/make-check-fn has-read-permissions?))
(defn decode-row (defn decode-row
[{:keys [features] :as row}] [{:keys [features subscription] :as row}]
(cond-> row (cond-> row
(some? features) (assoc :features (db/decode-pgarray features #{})))) (some? features) (assoc :features (db/decode-pgarray features #{}))
(some? subscription) (assoc :subscription (db/decode-transit-pgobject subscription))))
;; FIXME: move ;; FIXME: move
@ -126,16 +127,40 @@
(get-teams conn profile-id))) (get-teams conn profile-id)))
(def sql:get-teams-with-permissions (def sql:get-teams-with-permissions
"select t.*, "SELECT t.*,
tp.is_owner, tp.is_owner,
tp.is_admin, tp.is_admin,
tp.can_edit, tp.can_edit,
(t.id = ?) as is_default (t.id = ?) AS is_default
from team_profile_rel as tp FROM team_profile_rel AS tp
join team as t on (t.id = tp.team_id) JOIN team AS t ON (t.id = tp.team_id)
where t.deleted_at is null WHERE t.deleted_at IS null
and tp.profile_id = ? AND tp.profile_id = ?
order by tp.created_at asc") ORDER BY tp.created_at ASC")
(def sql:get-teams-with-permissions-and-subscription
"SELECT t.*,
tp.is_owner,
tp.is_admin,
tp.can_edit,
(t.id = ?) AS is_default,
jsonb_build_object(
'~:type', COALESCE(p.props->'~:subscription'->>'~:type', 'professional'),
'~:status', CASE COALESCE(p.props->'~:subscription'->>'~:type', 'professional')
WHEN 'professional' THEN 'active'
ELSE COALESCE(p.props->'~:subscription'->>'~:status', 'incomplete')
END
) AS subscription
FROM team_profile_rel AS tp
JOIN team AS t ON (t.id = tp.team_id)
JOIN team_profile_rel AS tpr
ON (tpr.team_id = t.id AND tpr.is_owner IS true)
JOIN profile AS p
ON (tpr.profile_id = p.id)
WHERE t.deleted_at IS null
AND tp.profile_id = ?
ORDER BY tp.created_at ASC;")
(defn process-permissions (defn process-permissions
[team] [team]
@ -150,13 +175,21 @@
(dissoc :is-owner :is-admin :can-edit) (dissoc :is-owner :is-admin :can-edit)
(assoc :permissions permissions)))) (assoc :permissions permissions))))
(def ^:private
xform:process-teams
(comp
(map decode-row)
(map process-permissions)))
(defn get-teams (defn get-teams
[conn profile-id] [conn profile-id]
(let [profile (profile/get-profile conn profile-id)] (let [profile (profile/get-profile conn profile-id)
(->> (db/exec! conn [sql:get-teams-with-permissions (:default-team-id profile) profile-id]) sql (if (contains? cf/flags :subscriptions)
(map decode-row) sql:get-teams-with-permissions-and-subscription
(map process-permissions) sql:get-teams-with-permissions)]
(vec))))
(->> (db/exec! conn [sql (:default-team-id profile) profile-id])
(into [] xform:process-teams))))
;; --- Query: Team (by ID) ;; --- Query: Team (by ID)

View file

@ -6,13 +6,17 @@
(ns app.srepl (ns app.srepl
"Server Repl." "Server Repl."
(:refer-clojure :exclude [read-line])
(:require (:require
[app.common.exceptions :as ex]
[app.common.json :as json]
[app.common.logging :as l] [app.common.logging :as l]
[app.config :as cf] [app.config :as cf]
[app.srepl.cli] [app.srepl.cli :as cli]
[app.srepl.main] [app.srepl.main]
[app.util.json :as json]
[app.util.locks :as locks] [app.util.locks :as locks]
[app.util.time :as dt]
[clojure.core :as c]
[clojure.core.server :as ccs] [clojure.core.server :as ccs]
[clojure.main :as cm] [clojure.main :as cm]
[integrant.core :as ig])) [integrant.core :as ig]))
@ -28,17 +32,80 @@
:init repl-init :init repl-init
:read ccs/repl-read)) :read ccs/repl-read))
(defn- ex->data
[cause phase]
(let [data (ex-data cause)
explain (ex/explain data)]
(cond-> {:phase phase
:code (get data :code :unknown)
:type (get data :type :unknown)
:hint (or (get data :hint) (ex-message cause))}
(some? explain)
(assoc :explain explain))))
(defn read-line
[]
(if-let [line (c/read-line)]
(try
(l/dbg :hint "decode" :data line)
(json/decode line :key-fn json/read-kebab-key)
(catch Throwable _cause
(l/warn :hint "unable to decode data" :data line)
nil))
::eof))
(defn json-repl (defn json-repl
[] []
(let [out *out* (let [lock (locks/create)
lock (locks/create)] out *out*
(ccs/prepl *in*
out-fn
(fn [m] (fn [m]
(binding [*out* out,
*flush-on-newline* true,
*print-readably* true]
(locks/locking lock (locks/locking lock
(println (json/encode-str m)))))))) (binding [*out* out]
(l/warn :hint "write" :data m)
(println (json/encode m :key-fn json/write-camel-key)))))
tapfn
(fn [val]
(out-fn {:tag :tap :val val}))]
(binding [*out* (PrintWriter-on #(out-fn {:tag :out :val %1}) nil true)
*err* (PrintWriter-on #(out-fn {:tag :err :val %1}) nil true)]
(try
(add-tap tapfn)
(loop []
(when (try
(let [data (read-line)
tpoint (dt/tpoint)]
(l/dbg :hint "received" :data (if (= data ::eof) "EOF" data))
(try
(when-not (= data ::eof)
(when-not (nil? data)
(let [result (cli/exec data)
elapsed (tpoint)]
(l/warn :hint "result" :data result)
(out-fn {:tag :ret
:val (if (instance? Throwable result)
(Throwable->map result)
result)
:elapsed (inst-ms elapsed)})))
true)
(catch Throwable cause
(let [elapsed (tpoint)]
(out-fn {:tag :ret
:err (ex->data cause :eval)
:elapsed (inst-ms elapsed)})
true))))
(catch Throwable cause
(out-fn {:tag :ret
:err (ex->data cause :read)})
true))
(recur)))
(finally
(remove-tap tapfn))))))
;; --- State initialization ;; --- State initialization

View file

@ -9,14 +9,23 @@
(:require (:require
[app.auth :as auth] [app.auth :as auth]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.db :as db] [app.db :as db]
[app.rpc.commands.auth :as cmd.auth] [app.rpc.commands.auth :as cmd.auth]
[app.rpc.commands.profile :as cmd.profile] [app.rpc.commands.profile :as cmd.profile]
[app.util.json :as json] [app.setup :as-alias setup]
[app.tokens :as tokens]
[app.util.time :as dt] [app.util.time :as dt]
[cuerdas.core :as str])) [cuerdas.core :as str]))
(defn coercer
[schema & {:as opts}]
(let [decode-fn (sm/decoder schema sm/json-transformer)
check-fn (sm/check-fn schema opts)]
(fn [data]
(-> data decode-fn check-fn))))
(defn- get-current-system (defn- get-current-system
[] []
(or (deref (requiring-resolve 'app.main/system)) (or (deref (requiring-resolve 'app.main/system))
@ -24,16 +33,21 @@
(defmulti ^:private exec-command ::cmd) (defmulti ^:private exec-command ::cmd)
(defmethod exec-command :default
[{:keys [::cmd]}]
(ex/raise :type :internal
:code :not-implemented
:hint (str/ffmt "command '%' not implemented" cmd)))
(defn exec (defn exec
"Entry point with external tools integrations that uses PREPL "Entry point with external tools integrations that uses PREPL
interface for interacting with running penpot backend." interface for interacting with running penpot backend."
[data] [data]
(let [data (json/decode data)] (-> {::cmd (get data :cmd)}
(-> {::cmd (keyword (:cmd data "default"))}
(merge (:params data)) (merge (:params data))
(exec-command)))) (exec-command)))
(defmethod exec-command :create-profile (defmethod exec-command "create-profile"
[{:keys [fullname email password is-active] [{:keys [fullname email password is-active]
:or {is-active true}}] :or {is-active true}}]
(some-> (get-current-system) (some-> (get-current-system)
@ -49,7 +63,7 @@
(->> (cmd.auth/create-profile! conn params) (->> (cmd.auth/create-profile! conn params)
(cmd.auth/create-profile-rels! conn))))))) (cmd.auth/create-profile-rels! conn)))))))
(defmethod exec-command :update-profile (defmethod exec-command "update-profile"
[{:keys [fullname email password is-active]}] [{:keys [fullname email password is-active]}]
(some-> (get-current-system) (some-> (get-current-system)
(db/tx-run! (db/tx-run!
@ -70,7 +84,12 @@
:deleted-at nil})] :deleted-at nil})]
(pos? (db/get-update-count res))))))))) (pos? (db/get-update-count res)))))))))
(defmethod exec-command :delete-profile (defmethod exec-command "echo"
[params]
params)
(defmethod exec-command "delete-profile"
[{:keys [email soft]}] [{:keys [email soft]}]
(when-not email (when-not email
(ex/raise :type :assertion (ex/raise :type :assertion
@ -88,7 +107,7 @@
{:email email}))] {:email email}))]
(pos? (db/get-update-count res))))))) (pos? (db/get-update-count res)))))))
(defmethod exec-command :search-profile (defmethod exec-command "search-profile"
[{:keys [email]}] [{:keys [email]}]
(when-not email (when-not email
(ex/raise :type :assertion (ex/raise :type :assertion
@ -102,12 +121,130 @@
" where email similar to ? order by created_at desc limit 100")] " where email similar to ? order by created_at desc limit 100")]
(db/exec! conn [sql email])))))) (db/exec! conn [sql email]))))))
(defmethod exec-command :derive-password (defmethod exec-command "derive-password"
[{:keys [password]}] [{:keys [password]}]
(auth/derive-password password)) (auth/derive-password password))
(defmethod exec-command :default (defmethod exec-command "authenticate"
[{:keys [::cmd]}] [{:keys [token]}]
(ex/raise :type :internal (when-let [system (get-current-system)]
:code :not-implemented (let [props (get system ::setup/props)]
:hint (str/ffmt "command '%' not implemented" (name cmd)))) (tokens/verify props {:token token :iss "authentication"}))))
(def ^:private schema:get-customer
[:map [:id ::sm/uuid]])
(def coerce-get-customer-params
(coercer schema:get-customer
:type :validation
:hint "invalid data provided for `get-customer` rpc call"))
(def sql:get-customer-slots
"WITH teams AS (
SELECT tpr.team_id AS id,
tpr.profile_id AS profile_id
FROM team_profile_rel AS tpr
WHERE tpr.is_owner IS true
AND tpr.profile_id = ?
), teams_with_slots AS (
SELECT tpr.team_id AS id,
count(*) AS total
FROM team_profile_rel AS tpr
WHERE tpr.team_id IN (SELECT id FROM teams)
AND tpr.can_edit IS true
GROUP BY 1
ORDER BY 2
)
SELECT max(total) AS total FROM teams_with_slots;")
(defn- get-customer-slots
[system profile-id]
(let [result (db/exec-one! system [sql:get-customer-slots profile-id])]
(:total result)))
(defmethod exec-command "get-customer"
[params]
(when-let [system (get-current-system)]
(let [{:keys [id] :as params} (coerce-get-customer-params params)
{:keys [props] :as profile} (cmd.profile/get-profile system id)]
{:id (get profile :id)
:name (get profile :fullname)
:email (get profile :email)
:num-editors (get-customer-slots system id)
:subscription (get props :subscription)})))
(def ^:private schema:customer-subscription
[:map {:title "CustomerSubscription"}
[:id ::sm/text]
[:customer-id ::sm/text]
[:type [:enum
"unlimited"
"professional"
"enterprise"]]
[:status [:enum
"active"
"canceled"
"incomplete"
"incomplete_expired"
"pass_due"
"paused"
"trialing"
"unpaid"]]
[:billing-period [:enum
"month"
"day"
"week"
"year"]]
[:quantity :int]
[:description [:maybe ::sm/text]]
[:created-at ::sm/timestamp]
[:start-date [:maybe ::sm/timestamp]]
[:ended-at [:maybe ::sm/timestamp]]
[:trial-end [:maybe ::sm/timestamp]]
[:trial-start [:maybe ::sm/timestamp]]
[:cancel-at [:maybe ::sm/timestamp]]
[:canceled-at [:maybe ::sm/timestamp]]
[:current-period-end ::sm/timestamp]
[:current-period-start ::sm/timestamp]
[:cancel-at-period-end :boolean]
[:cancellation-details
[:map {:title "CancellationDetails"}
[:comment [:maybe ::sm/text]]
[:reason [:maybe ::sm/text]]
[:feedback [:maybe
[:enum
"customer_service"
"low_quality"
"missing_feature"
"other"
"switched_service"
"too_complex"
"too_expensive"
"unused"]]]]]])
(def ^:private schema:update-customer-subscription
[:map
[:id ::sm/uuid]
[:subscription [:maybe schema:customer-subscription]]])
(def coerce-update-customer-subscription-params
(coercer schema:update-customer-subscription
:type :validation
:hint "invalid data provided for `update-customer-subscription` rpc call"))
(defmethod exec-command "update-customer-subscription"
[params]
(when-let [system (get-current-system)]
(let [{:keys [id subscription]} (coerce-update-customer-subscription-params params)
;; FIXME: locking
{:keys [props] :as profile} (cmd.profile/get-profile system id)
props (assoc props :subscription subscription)]
(db/update! system :profile
{:props (db/tjson props)}
{:id id}
{::db/return-keys false})
true)))

View file

@ -907,6 +907,22 @@
::oapi/type "string" ::oapi/type "string"
::oapi/format "iso"}}) ::oapi/format "iso"}})
(register!
{:type ::timestamp
:pred inst?
:type-properties
{:title "inst"
:description "Satisfies Inst protocol"
:error/message "should be an instant"
:gen/gen (->> (sg/small-int)
(sg/fmap (fn [v] (tm/parse-instant v))))
:decode/string tm/parse-instant
:encode/string inst-ms
:decode/json tm/parse-instant
:encode/json inst-ms
::oapi/type "string"
::oapi/format "number"}})
(register! (register!
{:type ::fn {:type ::fn
:pred fn?}) :pred fn?})