From 2df6f2b8b1620e1320fa78cdaa68c1cd31f79701 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 15 Apr 2025 15:25:15 +0200 Subject: [PATCH 01/10] :recycle: Refactor prepl interface Make prepl to be json message based protocol instead of clojure expression. This facilitates implementing internal RPC over socket server. --- backend/scripts/manage.py | 51 +++++++++----------- backend/src/app/srepl.clj | 89 ++++++++++++++++++++++++++++++----- backend/src/app/srepl/cli.clj | 25 +++++----- 3 files changed, 115 insertions(+), 50 deletions(-) diff --git a/backend/scripts/manage.py b/backend/scripts/manage.py index d3971e68d4..f731319c9a 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/srepl.clj b/backend/src/app/srepl.clj index fb53ca1e22..db28fd0386 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 25cf50d767..5dfb786ec2 100644 --- a/backend/src/app/srepl/cli.clj +++ b/backend/src/app/srepl/cli.clj @@ -13,7 +13,6 @@ [app.db :as db] [app.rpc.commands.auth :as cmd.auth] [app.rpc.commands.profile :as cmd.profile] - [app.util.json :as json] [app.util.time :as dt] [cuerdas.core :as str])) @@ -28,12 +27,11 @@ "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 +47,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 +68,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 +91,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,7 +105,7 @@ " 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)) @@ -110,4 +113,4 @@ [{:keys [::cmd]}] (ex/raise :type :internal :code :not-implemented - :hint (str/ffmt "command '%' not implemented" (name cmd)))) + :hint (str/ffmt "command '%' not implemented" cmd))) From 952ab032f97bbdb574663bdbe0ae76782399e2b1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 15 Apr 2025 15:31:20 +0200 Subject: [PATCH 02/10] :tada: Add authenticate prepl rpc method --- backend/src/app/srepl/cli.clj | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/src/app/srepl/cli.clj b/backend/src/app/srepl/cli.clj index 5dfb786ec2..188830b052 100644 --- a/backend/src/app/srepl/cli.clj +++ b/backend/src/app/srepl/cli.clj @@ -13,6 +13,8 @@ [app.db :as db] [app.rpc.commands.auth :as cmd.auth] [app.rpc.commands.profile :as cmd.profile] + [app.setup :as-alias setup] + [app.tokens :as tokens] [app.util.time :as dt] [cuerdas.core :as str])) @@ -109,6 +111,12 @@ [{:keys [password]}] (auth/derive-password password)) +(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"})))) + (defmethod exec-command :default [{:keys [::cmd]}] (ex/raise :type :internal From 5db5bc65debdae46174b2a90fd5d41f6476bd59f Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 15 Apr 2025 16:21:57 +0200 Subject: [PATCH 03/10] :tada: Add get-customer-prfile prepl rpc method --- backend/src/app/srepl/cli.clj | 36 ++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/backend/src/app/srepl/cli.clj b/backend/src/app/srepl/cli.clj index 188830b052..5eff92bb36 100644 --- a/backend/src/app/srepl/cli.clj +++ b/backend/src/app/srepl/cli.clj @@ -9,6 +9,7 @@ (: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] @@ -18,6 +19,13 @@ [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)) @@ -25,6 +33,12 @@ (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." @@ -117,8 +131,20 @@ (let [props (get system ::setup/props)] (tokens/verify props {:token token :iss "authentication"})))) -(defmethod exec-command :default - [{:keys [::cmd]}] - (ex/raise :type :internal - :code :not-implemented - :hint (str/ffmt "command '%' not implemented" cmd))) +(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")) + +(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) + :subscription (get props :subscription)}))) From 05c0f8d69f02b96c3e16db01c76313f9ec83c642 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 16 Apr 2025 12:48:05 +0200 Subject: [PATCH 04/10] :tada: Add update-customer-subscription prepl method --- backend/src/app/srepl/cli.clj | 76 +++++++++++++++++++++++++++++++ common/src/app/common/schema.cljc | 16 +++++++ 2 files changed, 92 insertions(+) diff --git a/backend/src/app/srepl/cli.clj b/backend/src/app/srepl/cli.clj index 5eff92bb36..c1b9e71022 100644 --- a/backend/src/app/srepl/cli.clj +++ b/backend/src/app/srepl/cli.clj @@ -148,3 +148,79 @@ :name (get profile :fullname) :email (get profile :email) :subscription (get props :subscription)}))) + +(def ^:private schema:customer-subscription + [:map {:title "CustomerSubscription"} + [:id ::sm/uuid] + [:customer-id ::sm/uuid] + [: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 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 8f68ea051a..50a317cda4 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?}) From cffac2a56acfd5ed84835df83cf2a6493127cb61 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 24 Apr 2025 10:45:25 +0200 Subject: [PATCH 05/10] :sparkles: Change schema for subscription --- backend/src/app/srepl/cli.clj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/app/srepl/cli.clj b/backend/src/app/srepl/cli.clj index c1b9e71022..8cb4a0fbbc 100644 --- a/backend/src/app/srepl/cli.clj +++ b/backend/src/app/srepl/cli.clj @@ -151,8 +151,8 @@ (def ^:private schema:customer-subscription [:map {:title "CustomerSubscription"} - [:id ::sm/uuid] - [:customer-id ::sm/uuid] + [:id ::sm/text] + [:customer-id ::sm/text] [:type [:enum "unlimited" "professional" From 4b81468c9c7d6719e428c831240a1a89738004d9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 24 Apr 2025 10:46:04 +0200 Subject: [PATCH 06/10] :sparkles: Allow subscription to be `nil` --- backend/src/app/srepl/cli.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/app/srepl/cli.clj b/backend/src/app/srepl/cli.clj index 8cb4a0fbbc..391ca3cadd 100644 --- a/backend/src/app/srepl/cli.clj +++ b/backend/src/app/srepl/cli.clj @@ -204,7 +204,7 @@ (def ^:private schema:update-customer-subscription [:map [:id ::sm/uuid] - [:subscription schema:customer-subscription]]) + [:subscription [:maybe schema:customer-subscription]]]) (def coerce-update-customer-subscription-params (coercer schema:update-customer-subscription From 1c224609b9b3b15b3c82be6fe37c433ab212e17d Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 24 Apr 2025 10:46:23 +0200 Subject: [PATCH 07/10] :sparkles: Add prototype for returning number of used slots on customer --- backend/src/app/srepl/cli.clj | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/src/app/srepl/cli.clj b/backend/src/app/srepl/cli.clj index 391ca3cadd..f457fb1e90 100644 --- a/backend/src/app/srepl/cli.clj +++ b/backend/src/app/srepl/cli.clj @@ -139,6 +139,10 @@ :type :validation :hint "invalid data provided for `get-customer` rpc call")) +(defn- get-customer-slots + [system] + 1) + (defmethod exec-command "get-customer" [params] (when-let [system (get-current-system)] @@ -147,6 +151,7 @@ {:id (get profile :id) :name (get profile :fullname) :email (get profile :email) + :used-slots (get-customer-slots system) :subscription (get props :subscription)}))) (def ^:private schema:customer-subscription From 18c7890f65ecd110cbf9724279dec4dcd018a9d6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 24 Apr 2025 12:12:24 +0200 Subject: [PATCH 08/10] :sparkles: Add proper impl for retrieving num of editors --- backend/src/app/srepl/cli.clj | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/backend/src/app/srepl/cli.clj b/backend/src/app/srepl/cli.clj index f457fb1e90..bb3750e046 100644 --- a/backend/src/app/srepl/cli.clj +++ b/backend/src/app/srepl/cli.clj @@ -139,9 +139,28 @@ :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] - 1) + [system profile-id] + (let [result (db/exec-one! system [sql:get-customer-slots profile-id])] + (:total result))) (defmethod exec-command "get-customer" [params] @@ -151,7 +170,7 @@ {:id (get profile :id) :name (get profile :fullname) :email (get profile :email) - :used-slots (get-customer-slots system) + :num-editors (get-customer-slots system id) :subscription (get props :subscription)}))) (def ^:private schema:customer-subscription From 38728eb3420bc579a2d9bf721a19a2444f1d128a Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 24 Apr 2025 13:37:42 +0200 Subject: [PATCH 09/10] :sparkles: Add the ability to known the subscription status on teams list --- backend/src/app/rpc/commands/teams.clj | 54 ++++++++++++++++++++------ 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 0dfccb8f4a..4304aa39d4 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -126,16 +126,38 @@ (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, + CASE COALESCE(p.props->'~:subscription'->>'~:status', 'unknown') + WHEN 'unknown' THEN false + WHEN 'canceled' THEN false + WHEN 'unpaid' THEN false + ELSE true + END AS is_subscription_active + 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 +172,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) From b20147255a5059cd0a5595b7ab03055b17267aa1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 24 Apr 2025 15:02:46 +0200 Subject: [PATCH 10/10] :sparkles: Add better approach for returning subscription on teams response --- backend/src/app/rpc/commands/teams.clj | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 4304aa39d4..2cf06564c4 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 @@ -143,12 +144,14 @@ tp.is_admin, tp.can_edit, (t.id = ?) AS is_default, - CASE COALESCE(p.props->'~:subscription'->>'~:status', 'unknown') - WHEN 'unknown' THEN false - WHEN 'canceled' THEN false - WHEN 'unpaid' THEN false - ELSE true - END AS is_subscription_active + + 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