diff --git a/backend/scripts/manage.py b/backend/scripts/manage.py index d3971e68d..f731319c9 100755 --- a/backend/scripts/manage.py +++ b/backend/scripts/manage.py @@ -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) - elif tag == "out": - print(result.get("val"), end="") - else: - raise RuntimeError("unexpected response from PREPL") + while True: + line = f.readline() + result = json.loads(line) + tag = result.get("tag", None) -def encode(val): - return json.dumps(json.dumps(val)) + if tag == "ret": + 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 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 } } diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 0dfccb8f4..2cf06564c 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -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) diff --git a/backend/src/app/srepl.clj b/backend/src/app/srepl.clj index fb53ca1e2..db28fd038 100644 --- a/backend/src/app/srepl.clj +++ b/backend/src/app/srepl.clj @@ -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* - (fn [m] - (binding [*out* out, - *flush-on-newline* true, - *print-readably* true] - (locks/locking lock - (println (json/encode-str m)))))))) + (let [lock (locks/create) + out *out* + + out-fn + (fn [m] + (locks/locking lock + (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 diff --git a/backend/src/app/srepl/cli.clj b/backend/src/app/srepl/cli.clj index 25cf50d76..bb3750e04 100644 --- a/backend/src/app/srepl/cli.clj +++ b/backend/src/app/srepl/cli.clj @@ -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"))} - (merge (:params data)) - (exec-command)))) + (-> {::cmd (get data :cmd)} + (merge (:params data)) + (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))) diff --git a/common/src/app/common/schema.cljc b/common/src/app/common/schema.cljc index 8f68ea051..50a317cda 100644 --- a/common/src/app/common/schema.cljc +++ b/common/src/app/common/schema.cljc @@ -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?})