|
| 1 | +import type { Client } from "@webstudio-is/postgrest/index.server"; |
| 2 | +import { |
| 3 | + type PlanFeatures, |
| 4 | + PlanFeaturesSchema, |
| 5 | + defaultPlanFeatures, |
| 6 | + parsePlansEnv, |
| 7 | + type Purchase, |
| 8 | +} from "./plan-features"; |
| 9 | + |
| 10 | +export { parsePlansEnv } from "./plan-features"; |
| 11 | + |
| 12 | +type PostgrestContext = { client: Client }; |
| 13 | + |
| 14 | +type AuthorizationContext = |
| 15 | + | { type: "token"; ownerId: string } |
| 16 | + | { type: "user"; userId: string } |
| 17 | + | { type: "service" } |
| 18 | + | { type: "anonymous" }; |
| 19 | + |
| 20 | +type PlanInfo = { |
| 21 | + planFeatures: PlanFeatures; |
| 22 | + purchases: Array<Purchase>; |
| 23 | +}; |
| 24 | + |
| 25 | +export const parseProductMeta = (meta: unknown): Partial<PlanFeatures> => { |
| 26 | + const result = PlanFeaturesSchema.partial().safeParse(meta); |
| 27 | + return result.success ? result.data : {}; |
| 28 | +}; |
| 29 | + |
| 30 | +export const mergeProductMetas = ( |
| 31 | + productMetas: Array<PlanFeatures> |
| 32 | +): PlanFeatures => { |
| 33 | + if (productMetas.length === 0) { |
| 34 | + return defaultPlanFeatures; |
| 35 | + } |
| 36 | + |
| 37 | + return Object.fromEntries( |
| 38 | + (Object.keys(defaultPlanFeatures) as Array<keyof PlanFeatures>).map( |
| 39 | + (key) => { |
| 40 | + const vals = productMetas.map((item) => item[key]); |
| 41 | + const merged = |
| 42 | + typeof defaultPlanFeatures[key] === "boolean" |
| 43 | + ? (vals as Array<boolean>).some(Boolean) |
| 44 | + : Math.max(...(vals as Array<number>)); |
| 45 | + return [key, merged]; |
| 46 | + } |
| 47 | + ) |
| 48 | + ) as PlanFeatures; |
| 49 | +}; |
| 50 | + |
| 51 | +export const buildPurchases = ( |
| 52 | + userProducts: Array<{ |
| 53 | + productId: string | null | undefined; |
| 54 | + subscriptionId: string | null | undefined; |
| 55 | + }>, |
| 56 | + productIdToName: Map<string, string> |
| 57 | +): Array<Purchase> => { |
| 58 | + const purchases: Array<Purchase> = []; |
| 59 | + for (const userProduct of userProducts) { |
| 60 | + if (userProduct.productId === null || userProduct.productId === undefined) { |
| 61 | + continue; |
| 62 | + } |
| 63 | + purchases.push({ |
| 64 | + planName: productIdToName.get(userProduct.productId) ?? "", |
| 65 | + subscriptionId: userProduct.subscriptionId ?? undefined, |
| 66 | + }); |
| 67 | + } |
| 68 | + return purchases; |
| 69 | +}; |
| 70 | + |
| 71 | +type Product = { id: string; name: string; meta: unknown }; |
| 72 | + |
| 73 | +// Products are admin-created and rarely change. Cache them for the lifetime of the |
| 74 | +// server instance to avoid a repeated DB round-trip on every request. |
| 75 | +// The promise itself is cached to deduplicate concurrent cold-start requests. |
| 76 | +let productCachePromise: Promise<Map<string, Product>> | undefined; |
| 77 | + |
| 78 | +const getProductCache = ( |
| 79 | + postgrest: PostgrestContext |
| 80 | +): Promise<Map<string, Product>> => { |
| 81 | + if (productCachePromise !== undefined) { |
| 82 | + return productCachePromise; |
| 83 | + } |
| 84 | + const promise: Promise<Map<string, Product>> = Promise.resolve( |
| 85 | + postgrest.client.from("Product").select("id, name, meta") |
| 86 | + ).then((result) => { |
| 87 | + if (result.error) { |
| 88 | + productCachePromise = undefined; |
| 89 | + console.error(result.error); |
| 90 | + throw new Error("Failed to fetch products"); |
| 91 | + } |
| 92 | + return new Map(result.data.map((p) => [p.id, p as Product])); |
| 93 | + }); |
| 94 | + productCachePromise = promise; |
| 95 | + return promise; |
| 96 | +}; |
| 97 | + |
| 98 | +export const getPlanInfo = async ( |
| 99 | + userIds: string[], |
| 100 | + context: { postgrest: PostgrestContext } |
| 101 | +): Promise<Map<string, PlanInfo>> => { |
| 102 | + const { postgrest } = context; |
| 103 | + |
| 104 | + if (userIds.length === 0) { |
| 105 | + return new Map(); |
| 106 | + } |
| 107 | + |
| 108 | + const userProductsResult = await postgrest.client |
| 109 | + .from("UserProduct") |
| 110 | + .select("userId, subscriptionId, productId") |
| 111 | + .in("userId", userIds); |
| 112 | + |
| 113 | + if (userProductsResult.error) { |
| 114 | + console.error(userProductsResult.error); |
| 115 | + throw new Error("Failed to fetch user products"); |
| 116 | + } |
| 117 | + |
| 118 | + const userProductsByUserId = new Map< |
| 119 | + string, |
| 120 | + Array<{ |
| 121 | + productId: string | null; |
| 122 | + subscriptionId: string | null; |
| 123 | + }> |
| 124 | + >(); |
| 125 | + |
| 126 | + for (const row of userProductsResult.data) { |
| 127 | + if (row.userId === null) { |
| 128 | + continue; |
| 129 | + } |
| 130 | + const rows = userProductsByUserId.get(row.userId) ?? []; |
| 131 | + rows.push({ |
| 132 | + productId: row.productId, |
| 133 | + subscriptionId: row.subscriptionId, |
| 134 | + }); |
| 135 | + userProductsByUserId.set(row.userId, rows); |
| 136 | + } |
| 137 | + |
| 138 | + const productIds = [ |
| 139 | + ...new Set( |
| 140 | + userProductsResult.data.flatMap(({ productId }) => |
| 141 | + productId === null || productId === undefined ? [] : [productId] |
| 142 | + ) |
| 143 | + ), |
| 144 | + ]; |
| 145 | + |
| 146 | + if (productIds.length === 0) { |
| 147 | + return new Map( |
| 148 | + userIds.map((userId) => [ |
| 149 | + userId, |
| 150 | + { planFeatures: defaultPlanFeatures, purchases: [] }, |
| 151 | + ]) |
| 152 | + ); |
| 153 | + } |
| 154 | + |
| 155 | + const allProducts = await getProductCache(postgrest); |
| 156 | + const productById = new Map( |
| 157 | + productIds.flatMap((id) => { |
| 158 | + const product = allProducts.get(id); |
| 159 | + return product !== undefined ? [[id, product] as const] : []; |
| 160 | + }) |
| 161 | + ); |
| 162 | + const productIdToName = new Map( |
| 163 | + [...productById.values()].map((product) => [product.id, product.name]) |
| 164 | + ); |
| 165 | + const plansByName = parsePlansEnv(process.env.PLANS ?? "[]"); |
| 166 | + |
| 167 | + return new Map( |
| 168 | + userIds.map((userId) => { |
| 169 | + const userProducts = userProductsByUserId.get(userId) ?? []; |
| 170 | + const productMetas = userProducts.flatMap(({ productId }) => { |
| 171 | + if (productId === null || productId === undefined) { |
| 172 | + return []; |
| 173 | + } |
| 174 | + const product = productById.get(productId); |
| 175 | + if (product === undefined) { |
| 176 | + return []; |
| 177 | + } |
| 178 | + return [ |
| 179 | + { |
| 180 | + ...(plansByName.get(product.name) ?? defaultPlanFeatures), |
| 181 | + ...parseProductMeta(product.meta), |
| 182 | + }, |
| 183 | + ]; |
| 184 | + }); |
| 185 | + |
| 186 | + return [ |
| 187 | + userId, |
| 188 | + { |
| 189 | + planFeatures: mergeProductMetas(productMetas), |
| 190 | + purchases: buildPurchases(userProducts, productIdToName), |
| 191 | + }, |
| 192 | + ]; |
| 193 | + }) |
| 194 | + ); |
| 195 | +}; |
| 196 | + |
| 197 | +/** Resets the module-level product cache. Only for use in tests. */ |
| 198 | +const resetProductCache = () => { |
| 199 | + productCachePromise = undefined; |
| 200 | +}; |
| 201 | + |
| 202 | +export const __testing__ = { |
| 203 | + parseProductMeta, |
| 204 | + mergeProductMetas, |
| 205 | + resetProductCache, |
| 206 | +}; |
| 207 | + |
| 208 | +export const getAuthorizationOwnerId = ( |
| 209 | + authorization: AuthorizationContext |
| 210 | +): string | undefined => { |
| 211 | + if (authorization.type === "token") { |
| 212 | + return authorization.ownerId; |
| 213 | + } |
| 214 | + if (authorization.type === "user") { |
| 215 | + return authorization.userId; |
| 216 | + } |
| 217 | + return undefined; |
| 218 | +}; |
0 commit comments