Skip to content

Commit 8ed5707

Browse files
authored
chore: Publish plans package (#5690)
1 parent 8de2fd4 commit 8ed5707

17 files changed

Lines changed: 473 additions & 368 deletions

apps/builder/app/routes/auth.dev.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { AUTH_PROVIDERS } from "~/shared/session";
55
import { clearReturnToCookie, returnToPath } from "~/services/cookie.server";
66
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
77
import { redirect, setNoStoreToRedirect } from "~/services/no-store-redirect";
8-
import { applyDevPlan } from "~/services/dev-plan.server";
8+
import { applyDevPlan } from "@webstudio-is/plans/index.server";
99
import { createPostgrestContext } from "~/shared/context.server";
1010

1111
export default function Dev() {

apps/builder/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
"@webstudio-is/sdk-components-react-radix": "workspace:*",
8383
"@webstudio-is/template": "workspace:*",
8484
"@webstudio-is/trpc-interface": "workspace:*",
85+
"@webstudio-is/plans": "workspace:*",
8586
"args-tokenizer": "^0.3.0",
8687
"bcp-47": "^2.1.0",
8788
"change-case": "^5.4.4",

packages/plans/package.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "@webstudio-is/plans",
3+
"version": "0.0.0-webstudio-version",
4+
"description": "Plan features and billing logic for Webstudio",
5+
"author": "Webstudio <github@webstudio.is>",
6+
"homepage": "https://webstudio.is",
7+
"license": "AGPL-3.0-or-later",
8+
"type": "module",
9+
"scripts": {
10+
"typecheck": "tsgo --noEmit",
11+
"test": "vitest run"
12+
},
13+
"dependencies": {
14+
"zod": "^3.24.2"
15+
},
16+
"devDependencies": {
17+
"@webstudio-is/postgrest": "workspace:*",
18+
"@webstudio-is/tsconfig": "workspace:*",
19+
"vitest": "^3.1.2"
20+
},
21+
"exports": {
22+
".": {
23+
"webstudio": "./src/index.ts",
24+
"import": "./src/index.ts"
25+
},
26+
"./index.server": {
27+
"webstudio": "./src/index.server.ts",
28+
"import": "./src/index.server.ts"
29+
}
30+
},
31+
"sideEffects": false
32+
}

apps/builder/app/services/dev-plan.server.test.ts renamed to packages/plans/src/dev-plan.server.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
json,
77
empty,
88
} from "@webstudio-is/postgrest/testing";
9-
import { applyDevPlan } from "~/services/dev-plan.server";
9+
import { applyDevPlan } from "./dev-plan.server";
1010

1111
const server = createTestServer();
1212

apps/builder/app/services/dev-plan.server.ts renamed to packages/plans/src/dev-plan.server.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import type { AppContext } from "@webstudio-is/trpc-interface/index.server";
1+
import type { Client } from "@webstudio-is/postgrest/index.server";
2+
3+
type PostgrestContext = { client: Client };
24

35
/**
46
* Upsert or delete dev plan rows in the DB for the given user email.
@@ -12,7 +14,7 @@ import type { AppContext } from "@webstudio-is/trpc-interface/index.server";
1214
export const applyDevPlan = async (
1315
email: string,
1416
planName: string | null,
15-
context: Pick<AppContext, "postgrest">
17+
context: { postgrest: PostgrestContext }
1618
) => {
1719
const { postgrest } = context;
1820
// Resolve userId from email (user was already created by the authenticator).

packages/plans/src/index.server.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export * from "./index";
2+
export {
3+
getPlanInfo,
4+
getAuthorizationOwnerId,
5+
parsePlansEnv,
6+
parseProductMeta,
7+
mergeProductMetas,
8+
buildPurchases,
9+
__testing__,
10+
} from "./plan-client.server";
11+
export { applyDevPlan } from "./dev-plan.server";

packages/plans/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export {
2+
PlanFeaturesSchema,
3+
defaultPlanFeatures,
4+
parsePlansEnv,
5+
} from "./plan-features";
6+
export type { PlanFeatures, Purchase } from "./plan-features";
File renamed without changes.
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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+
};
File renamed without changes.

0 commit comments

Comments
 (0)