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

View file

@ -76,9 +76,10 @@
(perms/make-check-fn has-read-permissions?))
(defn decode-row
[{:keys [features] :as row}]
[{:keys [features subscription] :as 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
@ -126,16 +127,40 @@
(get-teams conn profile-id)))
(def sql:get-teams-with-permissions
"select t.*,
"SELECT t.*,
tp.is_owner,
tp.is_admin,
tp.can_edit,
(t.id = ?) as is_default
from team_profile_rel as tp
join team as t on (t.id = tp.team_id)
where t.deleted_at is null
and tp.profile_id = ?
order by tp.created_at asc")
(t.id = ?) AS is_default
FROM team_profile_rel AS tp
JOIN team AS t ON (t.id = tp.team_id)
WHERE t.deleted_at IS null
AND tp.profile_id = ?
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
[team]
@ -150,13 +175,21 @@
(dissoc :is-owner :is-admin :can-edit)
(assoc :permissions permissions))))
(def ^:private
xform:process-teams
(comp
(map decode-row)
(map process-permissions)))
(defn get-teams
[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])
(map decode-row)
(map process-permissions)
(vec))))
(let [profile (profile/get-profile conn profile-id)
sql (if (contains? cf/flags :subscriptions)
sql:get-teams-with-permissions-and-subscription
sql:get-teams-with-permissions)]
(->> (db/exec! conn [sql (:default-team-id profile) profile-id])
(into [] xform:process-teams))))
;; --- Query: Team (by ID)

View file

@ -6,13 +6,17 @@
(ns app.srepl
"Server Repl."
(:refer-clojure :exclude [read-line])
(:require
[app.common.exceptions :as ex]
[app.common.json :as json]
[app.common.logging :as l]
[app.config :as cf]
[app.srepl.cli]
[app.srepl.cli :as cli]
[app.srepl.main]
[app.util.json :as json]
[app.util.locks :as locks]
[app.util.time :as dt]
[clojure.core :as c]
[clojure.core.server :as ccs]
[clojure.main :as cm]
[integrant.core :as ig]))
@ -28,17 +32,80 @@
:init repl-init
: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
[]
(let [out *out*
lock (locks/create)]
(ccs/prepl *in*
(let [lock (locks/create)
out *out*
out-fn
(fn [m]
(binding [*out* out,
*flush-on-newline* true,
*print-readably* true]
(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

View file

@ -9,14 +9,23 @@
(:require
[app.auth :as auth]
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.uuid :as uuid]
[app.db :as db]
[app.rpc.commands.auth :as cmd.auth]
[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]
[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
[]
(or (deref (requiring-resolve 'app.main/system))
@ -24,16 +33,21 @@
(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
"Entry point with external tools integrations that uses PREPL
interface for interacting with running penpot backend."
[data]
(let [data (json/decode data)]
(-> {::cmd (keyword (:cmd data "default"))}
(-> {::cmd (get data :cmd)}
(merge (:params data))
(exec-command))))
(exec-command)))
(defmethod exec-command :create-profile
(defmethod exec-command "create-profile"
[{:keys [fullname email password is-active]
:or {is-active true}}]
(some-> (get-current-system)
@ -49,7 +63,7 @@
(->> (cmd.auth/create-profile! conn params)
(cmd.auth/create-profile-rels! conn)))))))
(defmethod exec-command :update-profile
(defmethod exec-command "update-profile"
[{:keys [fullname email password is-active]}]
(some-> (get-current-system)
(db/tx-run!
@ -70,7 +84,12 @@
:deleted-at nil})]
(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]}]
(when-not email
(ex/raise :type :assertion
@ -88,7 +107,7 @@
{:email email}))]
(pos? (db/get-update-count res)))))))
(defmethod exec-command :search-profile
(defmethod exec-command "search-profile"
[{:keys [email]}]
(when-not email
(ex/raise :type :assertion
@ -102,12 +121,130 @@
" where email similar to ? order by created_at desc limit 100")]
(db/exec! conn [sql email]))))))
(defmethod exec-command :derive-password
(defmethod exec-command "derive-password"
[{:keys [password]}]
(auth/derive-password password))
(defmethod exec-command :default
[{:keys [::cmd]}]
(ex/raise :type :internal
:code :not-implemented
:hint (str/ffmt "command '%' not implemented" (name cmd))))
(defmethod exec-command "authenticate"
[{:keys [token]}]
(when-let [system (get-current-system)]
(let [props (get system ::setup/props)]
(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/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!
{:type ::fn
:pred fn?})