mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-26 21:17:40 +09:00
fix: Use project owner plan limits when project is shared via workspace (#5751)
This commit is contained in:
1 parent
7046cdcee9
commit
9c2116ade2
21 files changed
+725
-181
No files matched your search
@@ -784,8 +784,10 @@ const useCanAddDomain = () => {
|
||||
(domain) => domain.status === "ACTIVE" && domain.verified
|
||||
).length;
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load, activeDomainsCount]);
|
||||
if (project?.id !== undefined) {
|
||||
load({ projectId: project.id });
|
||||
}
|
||||
}, [load, activeDomainsCount, project?.id]);
|
||||
const canAddDomain = data
|
||||
? data.success && data.data < maxDomainsAllowedPerUser
|
||||
: true;
|
||||
@@ -795,9 +797,12 @@ const useCanAddDomain = () => {
|
||||
const useUserPublishCount = () => {
|
||||
const { load, data } = trpcClient.project.userPublishCount.useQuery();
|
||||
const { maxDailyPublishesPerUser } = useStore($permissions);
|
||||
const project = useStore($project);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
if (project?.id !== undefined) {
|
||||
load({ projectId: project.id });
|
||||
}
|
||||
}, [load, project?.id]);
|
||||
return {
|
||||
userPublishCount: data?.success ? data.data : 0,
|
||||
maxDailyPublishesPerUser,
|
||||
|
||||
@@ -52,7 +52,6 @@ export const action = async (props: ActionFunctionArgs) => {
|
||||
projectId,
|
||||
type,
|
||||
filename,
|
||||
maxAssetsPerProject: context.planFeatures.maxAssetsPerProject,
|
||||
},
|
||||
context
|
||||
);
|
||||
|
||||
@@ -32,7 +32,11 @@ const teamPlan: PlanFeatures = {
|
||||
};
|
||||
|
||||
const createContext = (
|
||||
overrides?: Partial<{ planFeatures: PlanFeatures; userId: string }>
|
||||
overrides?: Partial<{
|
||||
planFeatures: PlanFeatures;
|
||||
ownerPlanFeatures: PlanFeatures;
|
||||
userId: string;
|
||||
}>
|
||||
): AppContext =>
|
||||
({
|
||||
...testContext,
|
||||
@@ -43,7 +47,8 @@ const createContext = (
|
||||
planFeatures: overrides?.planFeatures ?? teamPlan,
|
||||
purchases: [],
|
||||
trpcCache: { setMaxAge: () => {} },
|
||||
getOwnerPlanFeatures: async () => overrides?.planFeatures ?? teamPlan,
|
||||
getOwnerPlanFeatures: async () =>
|
||||
overrides?.ownerPlanFeatures ?? overrides?.planFeatures ?? teamPlan,
|
||||
}) as unknown as AppContext;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -134,7 +139,11 @@ const setupAddMemberMocks = (opts?: {
|
||||
|
||||
describe("addMember", () => {
|
||||
test("rejects when maxWorkspaces <= 1 (free/pro plan)", async () => {
|
||||
// No DB mocks needed — the check happens before any queries.
|
||||
server.use(
|
||||
db.get("Workspace", () =>
|
||||
json({ id: "ws-1", userId: "owner-1", isDeleted: false })
|
||||
)
|
||||
);
|
||||
const ctx = createContext({ planFeatures: defaultPlanFeatures });
|
||||
const caller = createCaller(ctx);
|
||||
|
||||
@@ -150,6 +159,35 @@ describe("addMember", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects non-owners before billing sync", async () => {
|
||||
let paymentWorkerCalled = false;
|
||||
|
||||
server.use(
|
||||
db.get("Workspace", () =>
|
||||
json({ id: "ws-1", userId: "owner-1", isDeleted: false })
|
||||
),
|
||||
http.post(`${env.PAYMENT_WORKER_URL}/seats/sync`, () => {
|
||||
paymentWorkerCalled = true;
|
||||
return HttpResponse.json({ type: "success", seats: 1 });
|
||||
})
|
||||
);
|
||||
|
||||
const ctx = createContext({ userId: "member-1" });
|
||||
const caller = createCaller(ctx);
|
||||
|
||||
const result = await caller.addMember({
|
||||
workspaceId: "ws-1",
|
||||
email: "invitee@test.com",
|
||||
relation: "editors",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error).toContain("Only the workspace owner");
|
||||
}
|
||||
expect(paymentWorkerCalled).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects when workspace seat limit reached", async () => {
|
||||
const plan: PlanFeatures = { ...teamPlan, maxSeatsPerWorkspace: 2 };
|
||||
const ctx = createContext({ planFeatures: plan });
|
||||
@@ -320,6 +358,11 @@ describe("removeMember", () => {
|
||||
|
||||
describe("syncSeats", () => {
|
||||
test("rejects when maxWorkspaces <= 1", async () => {
|
||||
server.use(
|
||||
db.get("Workspace", () =>
|
||||
json({ id: "ws-1", userId: "owner-1", isDeleted: false })
|
||||
)
|
||||
);
|
||||
const ctx = createContext({ planFeatures: defaultPlanFeatures });
|
||||
const caller = createCaller(ctx);
|
||||
|
||||
@@ -335,6 +378,9 @@ describe("syncSeats", () => {
|
||||
let capturedBody: { workspaceId: string; delta?: number } | undefined;
|
||||
|
||||
server.use(
|
||||
db.get("Workspace", () =>
|
||||
json({ id: "ws-1", userId: "owner-1", isDeleted: false })
|
||||
),
|
||||
http.post(`${env.PAYMENT_WORKER_URL}/seats/sync`, async ({ request }) => {
|
||||
capturedBody = (await request.json()) as typeof capturedBody;
|
||||
return HttpResponse.json({ type: "success", seats: 3 });
|
||||
@@ -376,7 +422,13 @@ describe("listMembers", () => {
|
||||
json({ id: "ws-1", userId: "owner-1", isDeleted: false })
|
||||
),
|
||||
// listMembers: WorkspaceMember SELECT (members list + access check)
|
||||
db.get("WorkspaceMember", () => json(members)),
|
||||
db.get("WorkspaceMember", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.has("userId")) {
|
||||
return json({ userId: url.searchParams.get("userId") });
|
||||
}
|
||||
return json(members);
|
||||
}),
|
||||
// listMembers: Notification GET (pending invites for this workspace)
|
||||
db.get("Notification", () => json([])),
|
||||
// listMembers: User batch lookup
|
||||
@@ -449,6 +501,23 @@ describe("listMembers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("maxSeats uses workspace owner's seats for member callers", async () => {
|
||||
setupListMembersMocks({ transactionLog: null });
|
||||
|
||||
const ctx = createContext({
|
||||
userId: "m-1",
|
||||
planFeatures: defaultPlanFeatures,
|
||||
ownerPlanFeatures: teamPlan,
|
||||
});
|
||||
const caller = createCaller(ctx);
|
||||
const result = await caller.listMembers({ workspaceId: "ws-1" });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.maxSeats).toBe(4);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns owner, members, and pendingInvites", async () => {
|
||||
setupListMembersMocks({ memberCount: 2, transactionLog: null });
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
router,
|
||||
procedure,
|
||||
createErrorResponse,
|
||||
getPlanFeaturesByOwnerId,
|
||||
} from "@webstudio-is/trpc-interface/index.server";
|
||||
import { workspace as workspaceApi } from "@webstudio-is/project/index.server";
|
||||
import { roles } from "@webstudio-is/trpc-interface/authorize";
|
||||
@@ -118,7 +119,12 @@ export const workspaceRouter = router({
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
if (ctx.planFeatures.maxWorkspaces <= 1) {
|
||||
const { plan } = await workspaceApi.assertWorkspaceOwnerPlan(
|
||||
input.workspaceId,
|
||||
ctx
|
||||
);
|
||||
|
||||
if (plan.maxWorkspaces <= 1) {
|
||||
throw new Error("Upgrade your plan to invite members to workspaces.");
|
||||
}
|
||||
|
||||
@@ -132,7 +138,7 @@ export const workspaceRouter = router({
|
||||
);
|
||||
}
|
||||
|
||||
const { maxSeatsPerWorkspace } = ctx.planFeatures;
|
||||
const { maxSeatsPerWorkspace } = plan;
|
||||
if (maxSeatsPerWorkspace > 0) {
|
||||
const [membersResult, pendingResult] = await Promise.all([
|
||||
ctx.postgrest.client
|
||||
@@ -229,7 +235,12 @@ export const workspaceRouter = router({
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
if (ctx.planFeatures.maxWorkspaces <= 1) {
|
||||
const { plan } = await workspaceApi.assertWorkspaceOwnerPlan(
|
||||
input.workspaceId,
|
||||
ctx
|
||||
);
|
||||
|
||||
if (plan.maxWorkspaces <= 1) {
|
||||
throw new Error(
|
||||
"Upgrade your plan to manage workspace member roles."
|
||||
);
|
||||
@@ -258,7 +269,12 @@ export const workspaceRouter = router({
|
||||
.input(z.object({ workspaceId: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
if (ctx.planFeatures.maxWorkspaces <= 1) {
|
||||
const { plan } = await workspaceApi.assertWorkspaceOwnerPlan(
|
||||
input.workspaceId,
|
||||
ctx
|
||||
);
|
||||
|
||||
if (plan.maxWorkspaces <= 1) {
|
||||
throw new Error("Upgrade your plan to manage workspace seats.");
|
||||
}
|
||||
await syncSeats(input.workspaceId);
|
||||
@@ -273,10 +289,10 @@ export const workspaceRouter = router({
|
||||
.query(async ({ input, ctx }) => {
|
||||
try {
|
||||
const members = await workspaceApi.listMembers(input, ctx);
|
||||
const extraPaidSeats = await getExtraPaidSeats(
|
||||
members.owner.userId,
|
||||
ctx
|
||||
);
|
||||
const [ownerPlan, extraPaidSeats] = await Promise.all([
|
||||
getPlanFeaturesByOwnerId(members.owner.userId, ctx),
|
||||
getExtraPaidSeats(members.owner.userId, ctx),
|
||||
]);
|
||||
return {
|
||||
success: true as const,
|
||||
data: {
|
||||
@@ -284,7 +300,7 @@ export const workspaceRouter = router({
|
||||
// seatsIncluded = seats covered by the Team plan.
|
||||
// extraPaidSeats = extra seats from the Seats subscription.
|
||||
// Total capacity = included + extras.
|
||||
maxSeats: ctx.planFeatures.seatsIncluded + (extraPaidSeats ?? 0),
|
||||
maxSeats: ownerPlan.seatsIncluded + (extraPaidSeats ?? 0),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -316,8 +332,9 @@ export const workspaceRouter = router({
|
||||
throw new Error("Target workspace not found");
|
||||
}
|
||||
|
||||
const ownerPlan = await ctx.getOwnerPlanFeatures(
|
||||
targetWorkspace.data.userId
|
||||
const ownerPlan = await getPlanFeaturesByOwnerId(
|
||||
targetWorkspace.data.userId,
|
||||
ctx
|
||||
);
|
||||
|
||||
if (ownerPlan.maxWorkspaces <= 1) {
|
||||
|
||||
@@ -11,11 +11,23 @@ import { createUploadName } from "./upload";
|
||||
|
||||
const server = createTestServer();
|
||||
|
||||
const createContext = (): AppContext =>
|
||||
const createContext = ({
|
||||
maxAssetsPerProject = 350,
|
||||
maxWorkspaces = 20,
|
||||
ownerPlanCalls,
|
||||
}: {
|
||||
maxAssetsPerProject?: number;
|
||||
maxWorkspaces?: number;
|
||||
ownerPlanCalls?: string[];
|
||||
} = {}): AppContext =>
|
||||
({
|
||||
...testContext,
|
||||
authorization: { type: "user", userId: "user-1" },
|
||||
getOwnerPlanFeatures: () => Promise.resolve({}),
|
||||
planFeatures: { maxAssetsPerProject: 100 },
|
||||
getOwnerPlanFeatures: (userId: string) => {
|
||||
ownerPlanCalls?.push(userId);
|
||||
return Promise.resolve({ maxAssetsPerProject, maxWorkspaces });
|
||||
},
|
||||
}) as unknown as AppContext;
|
||||
|
||||
const ownershipHandler = db.get("Project", ({ request }) => {
|
||||
@@ -23,7 +35,7 @@ const ownershipHandler = db.get("Project", ({ request }) => {
|
||||
if (url.searchParams.has("userId")) {
|
||||
return json({ id: url.searchParams.get("id")?.replace("eq.", "") });
|
||||
}
|
||||
return json(null);
|
||||
return json({ userId: "owner-1", workspaceId: null });
|
||||
});
|
||||
|
||||
describe("createUploadName", () => {
|
||||
@@ -64,7 +76,6 @@ describe("createUploadName", () => {
|
||||
projectId: "project-1",
|
||||
type: "image/png",
|
||||
filename: "photo.png",
|
||||
maxAssetsPerProject: 350,
|
||||
},
|
||||
createContext()
|
||||
);
|
||||
@@ -91,10 +102,49 @@ describe("createUploadName", () => {
|
||||
projectId: "project-2",
|
||||
type: "image/png",
|
||||
filename: "photo.png",
|
||||
maxAssetsPerProject: 350,
|
||||
},
|
||||
createContext()
|
||||
)
|
||||
).rejects.toThrow("The maximum number of assets per project is 350.");
|
||||
});
|
||||
|
||||
test("uses project owner's asset limit for shared workspace projects", async () => {
|
||||
let insertedFile = false;
|
||||
const ownerPlanCalls: string[] = [];
|
||||
|
||||
server.use(
|
||||
db.get("Project", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.has("userId")) {
|
||||
return json(null);
|
||||
}
|
||||
return json({ userId: "team-owner" });
|
||||
}),
|
||||
db.get("WorkspaceProjectAuthorization", () =>
|
||||
json([{ relation: "editors" }])
|
||||
),
|
||||
db.head("Asset", () => empty({ headers: { "Content-Range": "*/100" } })),
|
||||
db.head("File", () => empty({ headers: { "Content-Range": "*/0" } })),
|
||||
db.post("File", () => {
|
||||
insertedFile = true;
|
||||
return empty({ status: 201 });
|
||||
}),
|
||||
db.post("Asset", () => empty({ status: 201 }))
|
||||
);
|
||||
|
||||
await expect(
|
||||
createUploadName(
|
||||
{
|
||||
assetId: "asset-3",
|
||||
projectId: "workspace-project",
|
||||
type: "image/png",
|
||||
filename: "photo.png",
|
||||
},
|
||||
createContext({ maxAssetsPerProject: 350, ownerPlanCalls })
|
||||
)
|
||||
).resolves.toMatch(/^photo_.+\.png$/);
|
||||
|
||||
expect(insertedFile).toBe(true);
|
||||
expect(ownerPlanCalls).toEqual(["team-owner"]);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type AppContext,
|
||||
authorizeProject,
|
||||
AuthorizationError,
|
||||
getProjectPlanFeatures,
|
||||
} from "@webstudio-is/trpc-interface/index.server";
|
||||
import type { Asset } from "@webstudio-is/sdk";
|
||||
import type { AssetClient } from "./client";
|
||||
@@ -14,7 +15,6 @@ type UploadData = {
|
||||
projectId: string;
|
||||
type: string;
|
||||
filename: string;
|
||||
maxAssetsPerProject: number;
|
||||
};
|
||||
|
||||
const UPLOADING_STALE_TIMEOUT = 1000 * 60 * 30; // 30 minutes
|
||||
@@ -23,7 +23,7 @@ export const createUploadName = async (
|
||||
data: UploadData,
|
||||
context: AppContext
|
||||
): Promise<string> => {
|
||||
const { assetId, projectId, maxAssetsPerProject, type, filename } = data;
|
||||
const { assetId, projectId, type, filename } = data;
|
||||
const canEdit = await authorizeProject.hasProjectPermit(
|
||||
{ projectId, permit: "edit" },
|
||||
context
|
||||
@@ -34,6 +34,11 @@ export const createUploadName = async (
|
||||
);
|
||||
}
|
||||
|
||||
const { maxAssetsPerProject } = await getProjectPlanFeatures(
|
||||
projectId,
|
||||
context
|
||||
);
|
||||
|
||||
/**
|
||||
* sometimes for example on request timeout we don't know what happened to the "UPLOADING" asset,
|
||||
* so we don't take into account assets with the "UPLOADING" status that were created more
|
||||
|
||||
@@ -15,7 +15,8 @@ const createContext = (overrides: Partial<AppContext> = {}): AppContext =>
|
||||
({
|
||||
...testContext,
|
||||
authorization: { type: "user", userId: "owner-1" },
|
||||
getOwnerPlanFeatures: () => Promise.resolve({}),
|
||||
getOwnerPlanFeatures: () =>
|
||||
Promise.resolve({ maxDomainsAllowedPerUser: 5, maxWorkspaces: 20 }),
|
||||
domain: {},
|
||||
deployment: {
|
||||
deploymentTrpc: {
|
||||
@@ -101,7 +102,6 @@ describe("create — domain limit guard (msw)", () => {
|
||||
{
|
||||
projectId: "proj-1",
|
||||
domain: "example.com",
|
||||
maxDomainsAllowedPerUser: 5,
|
||||
},
|
||||
createContext()
|
||||
);
|
||||
@@ -129,13 +129,70 @@ describe("create — domain limit guard (msw)", () => {
|
||||
{
|
||||
projectId: "proj-1",
|
||||
domain: "example.com",
|
||||
maxDomainsAllowedPerUser: 5,
|
||||
},
|
||||
createContext()
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
test("uses project owner's domain limit for workspace member admins", async () => {
|
||||
const ownerPlanCalls: string[] = [];
|
||||
let projectDomainInserted = false;
|
||||
|
||||
server.use(
|
||||
db.get("Project", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.has("userId")) {
|
||||
return json(null);
|
||||
}
|
||||
return json({
|
||||
id: "proj-1",
|
||||
userId: "owner-1",
|
||||
workspaceId: "ws-1",
|
||||
title: "Test",
|
||||
domain: "test",
|
||||
isDeleted: false,
|
||||
previewImageAsset: null,
|
||||
latestBuildVirtual: null,
|
||||
domainsVirtual: [],
|
||||
latestStaticBuild: [],
|
||||
});
|
||||
}),
|
||||
db.get("WorkspaceProjectAuthorization", () =>
|
||||
json([{ relation: "administrators" }])
|
||||
),
|
||||
db.head("Domain", () => empty({ headers: { "Content-Range": "*/1" } })),
|
||||
db.post("Domain", () => empty({ status: 204 })),
|
||||
db.get("Domain", () => json({ id: "domain-id-1" })),
|
||||
db.post("ProjectDomain", () => {
|
||||
projectDomainInserted = true;
|
||||
return empty({ status: 201 });
|
||||
})
|
||||
);
|
||||
|
||||
const result = await create(
|
||||
{
|
||||
projectId: "proj-1",
|
||||
domain: "example.com",
|
||||
},
|
||||
createContext({
|
||||
authorization: { type: "user", userId: "member-1" },
|
||||
planFeatures: { maxDomainsAllowedPerUser: 0 },
|
||||
getOwnerPlanFeatures: (userId: string) => {
|
||||
ownerPlanCalls.push(userId);
|
||||
return Promise.resolve({
|
||||
maxDomainsAllowedPerUser: 5,
|
||||
maxWorkspaces: 20,
|
||||
});
|
||||
},
|
||||
} as unknown as Partial<AppContext>)
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(projectDomainInserted).toBe(true);
|
||||
expect(ownerPlanCalls).toEqual(["owner-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type AppContext,
|
||||
AuthorizationError,
|
||||
createErrorResponse,
|
||||
getPlanFeaturesByOwnerId,
|
||||
} from "@webstudio-is/trpc-interface/index.server";
|
||||
import * as projectApi from "@webstudio-is/project/index.server";
|
||||
import { unpublishBuild } from "@webstudio-is/project-build/index.server";
|
||||
@@ -21,7 +22,6 @@ export const create = async (
|
||||
props: {
|
||||
projectId: Project["id"];
|
||||
domain: string;
|
||||
maxDomainsAllowedPerUser: number;
|
||||
},
|
||||
context: AppContext
|
||||
): Promise<Result> => {
|
||||
@@ -45,9 +45,10 @@ export const create = async (
|
||||
throw new AuthorizationError("Project must have project userId defined");
|
||||
}
|
||||
|
||||
const ownerPlan = await getPlanFeaturesByOwnerId(ownerId, context);
|
||||
const totalDomainsCount = await countTotalDomains(ownerId, context);
|
||||
|
||||
if (totalDomainsCount >= props.maxDomainsAllowedPerUser) {
|
||||
if (totalDomainsCount >= ownerPlan.maxDomainsAllowedPerUser) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
router,
|
||||
procedure,
|
||||
createErrorResponse,
|
||||
authorizeProject,
|
||||
getProjectOwnerId,
|
||||
AuthorizationError,
|
||||
} from "@webstudio-is/trpc-interface/index.server";
|
||||
import { Templates } from "@webstudio-is/sdk";
|
||||
import { db } from "../db";
|
||||
@@ -240,13 +243,10 @@ export const domainRouter = router({
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { planFeatures } = ctx;
|
||||
|
||||
return await db.create(
|
||||
{
|
||||
projectId: input.projectId,
|
||||
domain: input.domain,
|
||||
maxDomainsAllowedPerUser: planFeatures.maxDomainsAllowedPerUser,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
@@ -295,26 +295,43 @@ export const domainRouter = router({
|
||||
return createErrorResponse(error);
|
||||
}
|
||||
}),
|
||||
countTotalDomains: procedure.query(async ({ ctx }) => {
|
||||
try {
|
||||
if (
|
||||
ctx.authorization.type !== "user" &&
|
||||
ctx.authorization.type !== "token"
|
||||
) {
|
||||
throw new Error("Not authorized");
|
||||
countTotalDomains: procedure
|
||||
.input(z.object({ projectId: z.string() }).optional())
|
||||
.query(async ({ input, ctx }) => {
|
||||
try {
|
||||
if (
|
||||
ctx.authorization.type !== "user" &&
|
||||
ctx.authorization.type !== "token"
|
||||
) {
|
||||
throw new Error("Not authorized");
|
||||
}
|
||||
|
||||
let ownerId =
|
||||
ctx.authorization.type === "user"
|
||||
? ctx.authorization.userId
|
||||
: ctx.authorization.ownerId;
|
||||
|
||||
if (input?.projectId !== undefined) {
|
||||
const canView = await authorizeProject.hasProjectPermit(
|
||||
{ projectId: input.projectId, permit: "view" },
|
||||
ctx
|
||||
);
|
||||
|
||||
if (canView === false) {
|
||||
throw new AuthorizationError(
|
||||
"Not authorized to access this project"
|
||||
);
|
||||
}
|
||||
|
||||
ownerId = await getProjectOwnerId(input.projectId, ctx);
|
||||
}
|
||||
|
||||
const data = await db.countTotalDomains(ownerId, ctx);
|
||||
return { success: true, data } as const;
|
||||
} catch (error) {
|
||||
return createErrorResponse(error);
|
||||
}
|
||||
|
||||
const ownerId =
|
||||
ctx.authorization.type === "user"
|
||||
? ctx.authorization.userId
|
||||
: ctx.authorization.ownerId;
|
||||
|
||||
const data = await db.countTotalDomains(ownerId, ctx);
|
||||
return { success: true, data } as const;
|
||||
} catch (error) {
|
||||
return createErrorResponse(error);
|
||||
}
|
||||
}),
|
||||
}),
|
||||
|
||||
updateStatus: procedure
|
||||
.input(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
type AppContext,
|
||||
AuthorizationError,
|
||||
getPlanFeaturesByOwnerId,
|
||||
} from "@webstudio-is/trpc-interface/index.server";
|
||||
import {
|
||||
type NotificationType,
|
||||
@@ -590,7 +591,7 @@ const performAcceptSideEffect = async ({
|
||||
}
|
||||
|
||||
// Check receiver's plan limit before accepting the transfer
|
||||
const receiverPlan = await context.getOwnerPlanFeatures(userId);
|
||||
const receiverPlan = await getPlanFeaturesByOwnerId(userId, context);
|
||||
const projectCount = await context.postgrest.client
|
||||
.from("Project")
|
||||
.select("id", { count: "exact", head: true })
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
authorizeProject,
|
||||
type AppContext,
|
||||
AuthorizationError,
|
||||
getPlanFeaturesByOwnerId,
|
||||
} from "@webstudio-is/trpc-interface/index.server";
|
||||
import { createBuild } from "@webstudio-is/project-build/index.server";
|
||||
import { Title } from "../shared/project-schema";
|
||||
@@ -150,7 +151,7 @@ export const create = async (
|
||||
}
|
||||
|
||||
// Enforce the per-user project limit before creating anything
|
||||
const ownerPlan = await context.getOwnerPlanFeatures(projectOwnerUserId);
|
||||
const ownerPlan = await getPlanFeaturesByOwnerId(projectOwnerUserId, context);
|
||||
const projectCountResult = await context.postgrest.client
|
||||
.from("Project")
|
||||
.select("id", { count: "exact", head: true })
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Database } from "@webstudio-is/postgrest/index.server";
|
||||
import {
|
||||
type AppContext,
|
||||
AuthorizationError,
|
||||
getPlanFeaturesByOwnerId,
|
||||
} from "@webstudio-is/trpc-interface/index.server";
|
||||
import { softDeleteProject } from "./project";
|
||||
import { defaultRole, type Role } from "@webstudio-is/trpc-interface/authorize";
|
||||
@@ -247,7 +248,7 @@ export const findMany = async (userId: string, context: AppContext) => {
|
||||
// break the entire workspace list. Failed checks default to not-downgraded.
|
||||
const checks = await Promise.allSettled(
|
||||
ownerIds.map(async (ownerId) => {
|
||||
const plan = await context.getOwnerPlanFeatures(ownerId);
|
||||
const plan = await getPlanFeaturesByOwnerId(ownerId, context);
|
||||
if (plan.maxWorkspaces <= 1) {
|
||||
return { ownerId, isDowngraded: true };
|
||||
}
|
||||
@@ -281,7 +282,7 @@ export const findMany = async (userId: string, context: AppContext) => {
|
||||
return [...ownedWithRelation, ...memberWithRelation];
|
||||
};
|
||||
|
||||
const assertOwner = async (
|
||||
const assertWorkspaceOwnerByUserId = async (
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
context: AppContext
|
||||
@@ -304,6 +305,25 @@ const assertOwner = async (
|
||||
return workspace.data;
|
||||
};
|
||||
|
||||
export const assertWorkspaceOwner = async (
|
||||
workspaceId: string,
|
||||
context: AppContext
|
||||
) => {
|
||||
const userId = assertUser(context);
|
||||
return await assertWorkspaceOwnerByUserId(workspaceId, userId, context);
|
||||
};
|
||||
|
||||
export const assertWorkspaceOwnerPlan = async (
|
||||
workspaceId: string,
|
||||
context: AppContext
|
||||
) => {
|
||||
const workspace = await assertWorkspaceOwner(workspaceId, context);
|
||||
return {
|
||||
workspace,
|
||||
plan: await getPlanFeaturesByOwnerId(workspace.userId, context),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Count all current members (non-removed) plus pending invites across all
|
||||
* non-deleted workspaces owned by userId.
|
||||
@@ -368,7 +388,7 @@ export const addMember = async (
|
||||
context: AppContext
|
||||
): Promise<{ notificationId: string }> => {
|
||||
const userId = assertUser(context);
|
||||
await assertOwner(workspaceId, userId, context);
|
||||
await assertWorkspaceOwnerByUserId(workspaceId, userId, context);
|
||||
|
||||
// Look up the user by email
|
||||
const user = await context.postgrest.client
|
||||
@@ -433,7 +453,7 @@ export const updateRole = async (
|
||||
context: AppContext
|
||||
) => {
|
||||
const userId = assertUser(context);
|
||||
await assertOwner(workspaceId, userId, context);
|
||||
await assertWorkspaceOwnerByUserId(workspaceId, userId, context);
|
||||
|
||||
if (memberUserId === userId) {
|
||||
throw new Error("Cannot change the workspace owner's role");
|
||||
@@ -484,7 +504,7 @@ export const removeMember = async (
|
||||
}
|
||||
} else {
|
||||
// Only the owner can remove other members.
|
||||
await assertOwner(workspaceId, userId, context);
|
||||
await assertWorkspaceOwnerByUserId(workspaceId, userId, context);
|
||||
}
|
||||
|
||||
const result = await context.postgrest.client
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
createTestServer,
|
||||
db,
|
||||
json,
|
||||
testContext,
|
||||
} from "@webstudio-is/postgrest/testing";
|
||||
import {
|
||||
createCallerFactory,
|
||||
type AppContext,
|
||||
} from "@webstudio-is/trpc-interface/index.server";
|
||||
import { defaultPlanFeatures } from "@webstudio-is/plans";
|
||||
import { projectRouter } from "./project-router";
|
||||
|
||||
const server = createTestServer();
|
||||
const createCaller = createCallerFactory(projectRouter);
|
||||
|
||||
const createContext = (
|
||||
overrides: Partial<Pick<AppContext, "authorization" | "planFeatures">> = {}
|
||||
): AppContext =>
|
||||
({
|
||||
...testContext,
|
||||
authorization: { type: "user", userId: "member-1" },
|
||||
planFeatures: defaultPlanFeatures,
|
||||
getOwnerPlanFeatures: () =>
|
||||
Promise.resolve({
|
||||
...defaultPlanFeatures,
|
||||
maxWorkspaces: 20,
|
||||
}),
|
||||
...overrides,
|
||||
}) as unknown as AppContext;
|
||||
|
||||
describe("userPublishCount", () => {
|
||||
test("counts publishes for the project owner when called by a workspace member", async () => {
|
||||
server.use(
|
||||
db.get("Project", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.has("userId")) {
|
||||
return json(null);
|
||||
}
|
||||
return json({ userId: "owner-1" });
|
||||
}),
|
||||
db.get("WorkspaceProjectAuthorization", () =>
|
||||
json([{ relation: "editors" }])
|
||||
),
|
||||
db.get("user_publish_count", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
expect(url.searchParams.get("user_id")).toBe("eq.owner-1");
|
||||
return json({ count: 42 });
|
||||
})
|
||||
);
|
||||
|
||||
const caller = createCaller(createContext());
|
||||
const result = await caller.userPublishCount({ projectId: "proj-1" });
|
||||
|
||||
expect(result).toEqual({ success: true, data: 42 });
|
||||
});
|
||||
|
||||
test("counts publishes for the caller when projectId is omitted", async () => {
|
||||
server.use(
|
||||
db.get("user_publish_count", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
expect(url.searchParams.get("user_id")).toBe("eq.member-1");
|
||||
return json({ count: 7 });
|
||||
})
|
||||
);
|
||||
|
||||
const caller = createCaller(createContext());
|
||||
const result = await caller.userPublishCount();
|
||||
|
||||
expect(result).toEqual({ success: true, data: 7 });
|
||||
});
|
||||
|
||||
test("uses token owner when projectId is omitted for token auth", async () => {
|
||||
server.use(
|
||||
db.get("user_publish_count", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
expect(url.searchParams.get("user_id")).toBe("eq.token-owner");
|
||||
return json({ count: 9 });
|
||||
})
|
||||
);
|
||||
|
||||
const caller = createCaller(
|
||||
createContext({
|
||||
authorization: {
|
||||
type: "token",
|
||||
authToken: "token-1",
|
||||
ownerId: "token-owner",
|
||||
},
|
||||
})
|
||||
);
|
||||
const result = await caller.userPublishCount();
|
||||
|
||||
expect(result).toEqual({ success: true, data: 9 });
|
||||
});
|
||||
|
||||
test("does not count publishes when caller cannot view the project", async () => {
|
||||
let publishCountQueried = false;
|
||||
|
||||
server.use(
|
||||
db.get("Project", () => json(null)),
|
||||
db.get("WorkspaceProjectAuthorization", () => json([])),
|
||||
db.get("user_publish_count", () => {
|
||||
publishCountQueried = true;
|
||||
return json({ count: 0 });
|
||||
})
|
||||
);
|
||||
|
||||
const caller = createCaller(createContext());
|
||||
const result = await caller.userPublishCount({ projectId: "private" });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if ("error" in result) {
|
||||
expect(result.error).toContain("Not authorized");
|
||||
}
|
||||
expect(publishCountQueried).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AuthorizationError,
|
||||
authorizeProject,
|
||||
createErrorResponse,
|
||||
getProjectOwnerId,
|
||||
} from "@webstudio-is/trpc-interface/index.server";
|
||||
import { Title } from "../shared/project-schema";
|
||||
import { MarketplaceApprovalStatus } from "../shared/marketplace-schema";
|
||||
@@ -84,34 +85,54 @@ export const projectRouter = router({
|
||||
return projectIds.map((project) => project.id);
|
||||
}),
|
||||
|
||||
userPublishCount: procedure.query(async ({ ctx }) => {
|
||||
try {
|
||||
if (
|
||||
ctx.authorization.type !== "user" &&
|
||||
ctx.authorization.type !== "token"
|
||||
) {
|
||||
throw new Error("Not authorized");
|
||||
userPublishCount: procedure
|
||||
.input(z.object({ projectId: z.string() }).optional())
|
||||
.query(async ({ input, ctx }) => {
|
||||
try {
|
||||
if (
|
||||
ctx.authorization.type !== "user" &&
|
||||
ctx.authorization.type !== "token"
|
||||
) {
|
||||
throw new Error("Not authorized");
|
||||
}
|
||||
const userId =
|
||||
ctx.authorization.type === "user"
|
||||
? ctx.authorization.userId
|
||||
: ctx.authorization.ownerId;
|
||||
|
||||
let ownerId = userId;
|
||||
|
||||
if (input?.projectId !== undefined) {
|
||||
const canView = await authorizeProject.hasProjectPermit(
|
||||
{ projectId: input.projectId, permit: "view" },
|
||||
ctx
|
||||
);
|
||||
|
||||
if (canView === false) {
|
||||
throw new AuthorizationError(
|
||||
"Not authorized to access this project"
|
||||
);
|
||||
}
|
||||
|
||||
ownerId = await getProjectOwnerId(input.projectId, ctx);
|
||||
}
|
||||
|
||||
const result = await ctx.postgrest.client
|
||||
.from("user_publish_count")
|
||||
.select("count")
|
||||
.eq("user_id", ownerId)
|
||||
.maybeSingle();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data?.count ?? 0,
|
||||
};
|
||||
} catch (error) {
|
||||
return createErrorResponse(error);
|
||||
}
|
||||
const userId =
|
||||
ctx.authorization.type === "user"
|
||||
? ctx.authorization.userId
|
||||
: ctx.authorization.ownerId;
|
||||
const result = await ctx.postgrest.client
|
||||
.from("user_publish_count")
|
||||
.select("count")
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data?.count ?? 0,
|
||||
};
|
||||
} catch (error) {
|
||||
return createErrorResponse(error);
|
||||
}
|
||||
}),
|
||||
}),
|
||||
|
||||
publishedBuilds: procedure
|
||||
.input(
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
router,
|
||||
procedure,
|
||||
createErrorResponse,
|
||||
getPlanFeaturesByOwnerId,
|
||||
} from "@webstudio-is/trpc-interface/index.server";
|
||||
import { roles } from "@webstudio-is/trpc-interface/authorize";
|
||||
|
||||
@@ -77,7 +78,12 @@ export const workspaceRouter = router({
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
if (ctx.planFeatures.maxWorkspaces <= 1) {
|
||||
const { plan } = await workspaceApi.assertWorkspaceOwnerPlan(
|
||||
input.workspaceId,
|
||||
ctx
|
||||
);
|
||||
|
||||
if (plan.maxWorkspaces <= 1) {
|
||||
throw new Error("Upgrade your plan to invite members to workspaces.");
|
||||
}
|
||||
|
||||
@@ -98,7 +104,12 @@ export const workspaceRouter = router({
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
if (ctx.planFeatures.maxWorkspaces <= 1) {
|
||||
const { plan } = await workspaceApi.assertWorkspaceOwnerPlan(
|
||||
input.workspaceId,
|
||||
ctx
|
||||
);
|
||||
|
||||
if (plan.maxWorkspaces <= 1) {
|
||||
throw new Error(
|
||||
"Upgrade your plan to manage workspace member roles."
|
||||
);
|
||||
@@ -158,8 +169,9 @@ export const workspaceRouter = router({
|
||||
throw new Error("Target workspace not found");
|
||||
}
|
||||
|
||||
const ownerPlan = await ctx.getOwnerPlanFeatures(
|
||||
targetWorkspace.data.userId
|
||||
const ownerPlan = await getPlanFeaturesByOwnerId(
|
||||
targetWorkspace.data.userId,
|
||||
ctx
|
||||
);
|
||||
|
||||
if (ownerPlan.maxWorkspaces <= 1) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
__testing__,
|
||||
} from "./project.server";
|
||||
|
||||
const { isRolePermitted, getWorkspaceOwnerIdForProject } = __testing__;
|
||||
const { isRolePermitted } = __testing__;
|
||||
|
||||
describe("isRolePermitted", () => {
|
||||
describe("workspace owner (own relation)", () => {
|
||||
@@ -175,41 +175,6 @@ const makeUserCtx = (userId = "user-1", maxWorkspaces = 5): AppContext =>
|
||||
}),
|
||||
}) as unknown as AppContext;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getWorkspaceOwnerIdForProject
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getWorkspaceOwnerIdForProject (msw)", () => {
|
||||
test("returns undefined when project has no workspace", async () => {
|
||||
const projectId = uid();
|
||||
server.use(
|
||||
db.get("Project", () => json({ id: projectId, workspaceId: null }))
|
||||
);
|
||||
const result = await getWorkspaceOwnerIdForProject(projectId, testContext);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns workspace owner userId when workspace exists", async () => {
|
||||
const projectId = uid();
|
||||
server.use(
|
||||
db.get("Project", () => json({ id: projectId, workspaceId: "ws-1" })),
|
||||
db.get("Workspace", () => json({ id: "ws-1", userId: "owner-99" }))
|
||||
);
|
||||
const result = await getWorkspaceOwnerIdForProject(projectId, testContext);
|
||||
expect(result).toBe("owner-99");
|
||||
});
|
||||
|
||||
test("returns undefined when workspace not found", async () => {
|
||||
const projectId = uid();
|
||||
server.use(
|
||||
db.get("Project", () => json({ id: projectId, workspaceId: "ws-gone" })),
|
||||
db.get("Workspace", () => json(null))
|
||||
);
|
||||
const result = await getWorkspaceOwnerIdForProject(projectId, testContext);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// checkProjectPermit — user auth
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -354,18 +319,16 @@ describe("hasProjectPermit — workspace downgrade guard (msw)", () => {
|
||||
test("denies workspace member when owner plan has maxWorkspaces <= 1", async () => {
|
||||
const projectId = uid();
|
||||
server.use(
|
||||
// Per-call routing: ownership check uses userId param; workspaceId lookup does not
|
||||
db.get("Project", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.has("userId")) {
|
||||
return json(null);
|
||||
}
|
||||
return json({ id: projectId, workspaceId: "ws-1" });
|
||||
return json({ id: projectId, userId: "owner-1" });
|
||||
}),
|
||||
db.get("WorkspaceProjectAuthorization", () =>
|
||||
json([{ relation: "editors" }])
|
||||
),
|
||||
db.get("Workspace", () => json({ id: "ws-1", userId: "owner-1" }))
|
||||
)
|
||||
);
|
||||
const ctx = makeUserCtx("user-2", 1);
|
||||
|
||||
@@ -381,12 +344,11 @@ describe("hasProjectPermit — workspace downgrade guard (msw)", () => {
|
||||
if (url.searchParams.has("userId")) {
|
||||
return json(null);
|
||||
}
|
||||
return json({ id: projectId, workspaceId: "ws-1" });
|
||||
return json({ id: projectId, userId: "owner-1" });
|
||||
}),
|
||||
db.get("WorkspaceProjectAuthorization", () =>
|
||||
json([{ relation: "editors" }])
|
||||
),
|
||||
db.get("Workspace", () => json({ id: "ws-1", userId: "owner-1" }))
|
||||
)
|
||||
);
|
||||
const ctx = makeUserCtx("user-2", 5);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AppContext } from "../context/context.server";
|
||||
import type { Role } from "./role";
|
||||
import memoize from "memoize";
|
||||
import { getProjectPlanFeatures } from "../context/project-plan.server";
|
||||
|
||||
type Relation = Role;
|
||||
|
||||
@@ -228,34 +229,6 @@ export const checkProjectPermit = async ({
|
||||
return allowed;
|
||||
};
|
||||
|
||||
/**
|
||||
* Look up the workspace owner's userId for a given project.
|
||||
* Returns undefined when the project has no workspace.
|
||||
*/
|
||||
const getWorkspaceOwnerIdForProject = async (
|
||||
projectId: string,
|
||||
context: Pick<AppContext, "postgrest">
|
||||
): Promise<string | undefined> => {
|
||||
const project = await context.postgrest.client
|
||||
.from("Project")
|
||||
.select("workspaceId")
|
||||
.eq("id", projectId)
|
||||
.maybeSingle();
|
||||
|
||||
if (project.error || project.data?.workspaceId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspace = await context.postgrest.client
|
||||
.from("Workspace")
|
||||
.select("userId")
|
||||
.eq("id", project.data.workspaceId)
|
||||
.eq("isDeleted", false)
|
||||
.maybeSingle();
|
||||
|
||||
return workspace.data?.userId ?? undefined;
|
||||
};
|
||||
|
||||
export const hasProjectPermit = async (
|
||||
props: {
|
||||
projectId: string;
|
||||
@@ -287,11 +260,11 @@ export const hasProjectPermit = async (
|
||||
}
|
||||
|
||||
// Workspace downgrade check: when a workspace member accesses a project,
|
||||
// verify the workspace owner's plan still supports workspace features.
|
||||
// Direct project owners and workspace owners are not affected.
|
||||
// verify the project owner's plan still supports workspace features.
|
||||
// Direct project owners are not affected.
|
||||
if (authorization.type === "user") {
|
||||
// "own" permit is only granted to direct owners and workspace owners —
|
||||
// both are unaffected by downgrade. This call is memoized.
|
||||
// "own" permit is only granted to direct project owners.
|
||||
// They are unaffected by downgrade. This call is memoized.
|
||||
const isOwner = await checkProjectPermit({
|
||||
projectId: props.projectId,
|
||||
permit: "own",
|
||||
@@ -300,17 +273,10 @@ export const hasProjectPermit = async (
|
||||
});
|
||||
|
||||
if (isOwner === false) {
|
||||
// User is a workspace member — verify the owner's plan
|
||||
const workspaceOwnerId = await getWorkspaceOwnerIdForProject(
|
||||
props.projectId,
|
||||
context
|
||||
);
|
||||
|
||||
if (workspaceOwnerId !== undefined) {
|
||||
const ownerPlan = await context.getOwnerPlanFeatures(workspaceOwnerId);
|
||||
if (ownerPlan.maxWorkspaces <= 1) {
|
||||
return false;
|
||||
}
|
||||
// User is a workspace member — verify the project owner's plan.
|
||||
const ownerPlan = await getProjectPlanFeatures(props.projectId, context);
|
||||
if (ownerPlan.maxWorkspaces <= 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -351,5 +317,4 @@ export const getProjectPermit = async (
|
||||
|
||||
export const __testing__ = {
|
||||
isRolePermitted,
|
||||
getWorkspaceOwnerIdForProject,
|
||||
};
|
||||
@@ -103,8 +103,8 @@ export type AppContext = {
|
||||
createTokenContext: (token: string) => Promise<AppContext>;
|
||||
/**
|
||||
* Resolves plan features for a given user ID.
|
||||
* Used by workspace authorization to check whether the workspace owner's plan
|
||||
* still supports workspace features (maxWorkspaces > 1) after a potential downgrade.
|
||||
* Use the shared plan helper when possible so repeated lookups are cached
|
||||
* consistently within the request.
|
||||
*/
|
||||
getOwnerPlanFeatures: (userId: string) => Promise<PlanFeatures>;
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
createTestServer,
|
||||
db,
|
||||
json,
|
||||
testContext,
|
||||
} from "@webstudio-is/postgrest/testing";
|
||||
import { defaultPlanFeatures } from "@webstudio-is/plans";
|
||||
import type { AppContext } from "./context.server";
|
||||
import {
|
||||
getPlanFeaturesByOwnerId,
|
||||
getProjectOwnerId,
|
||||
getProjectPlanFeatures,
|
||||
} from "./project-plan.server";
|
||||
|
||||
const server = createTestServer();
|
||||
|
||||
const createContext = (
|
||||
getOwnerPlanFeatures: AppContext["getOwnerPlanFeatures"] = async () =>
|
||||
defaultPlanFeatures
|
||||
): AppContext =>
|
||||
({
|
||||
...testContext,
|
||||
getOwnerPlanFeatures,
|
||||
}) as unknown as AppContext;
|
||||
|
||||
describe("project plan helpers", () => {
|
||||
test("resolves project owner id from Project.userId and caches it per context", async () => {
|
||||
let projectQueries = 0;
|
||||
server.use(
|
||||
db.get("Project", () => {
|
||||
projectQueries += 1;
|
||||
return json({ userId: "owner-1" });
|
||||
})
|
||||
);
|
||||
|
||||
const context = createContext();
|
||||
|
||||
await expect(getProjectOwnerId("project-1", context)).resolves.toBe(
|
||||
"owner-1"
|
||||
);
|
||||
await expect(getProjectOwnerId("project-1", context)).resolves.toBe(
|
||||
"owner-1"
|
||||
);
|
||||
|
||||
expect(projectQueries).toBe(1);
|
||||
});
|
||||
|
||||
test("resolves project plan features from the project owner and caches owner plan", async () => {
|
||||
const ownerPlanCalls: string[] = [];
|
||||
server.use(db.get("Project", () => json({ userId: "owner-1" })));
|
||||
|
||||
const context = createContext(async (ownerId) => {
|
||||
ownerPlanCalls.push(ownerId);
|
||||
return {
|
||||
...defaultPlanFeatures,
|
||||
maxAssetsPerProject: 350,
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
getProjectPlanFeatures("project-1", context)
|
||||
).resolves.toMatchObject({
|
||||
maxAssetsPerProject: 350,
|
||||
});
|
||||
await expect(
|
||||
getProjectPlanFeatures("project-1", context)
|
||||
).resolves.toMatchObject({
|
||||
maxAssetsPerProject: 350,
|
||||
});
|
||||
|
||||
expect(ownerPlanCalls).toEqual(["owner-1"]);
|
||||
});
|
||||
|
||||
test("rejects projects without an owner and retries after the failed cache entry is cleared", async () => {
|
||||
let projectQueries = 0;
|
||||
server.use(
|
||||
db.get("Project", () => {
|
||||
projectQueries += 1;
|
||||
return json({
|
||||
userId: projectQueries === 1 ? null : "owner-2",
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const context = createContext();
|
||||
|
||||
await expect(getProjectOwnerId("project-2", context)).rejects.toThrow(
|
||||
"Project must have project userId defined"
|
||||
);
|
||||
await expect(getProjectOwnerId("project-2", context)).resolves.toBe(
|
||||
"owner-2"
|
||||
);
|
||||
expect(projectQueries).toBe(2);
|
||||
});
|
||||
|
||||
test("clears failed owner-plan cache entries before retrying", async () => {
|
||||
let ownerPlanCalls = 0;
|
||||
const context = createContext(async () => {
|
||||
ownerPlanCalls += 1;
|
||||
if (ownerPlanCalls === 1) {
|
||||
throw new Error("temporary plan failure");
|
||||
}
|
||||
return {
|
||||
...defaultPlanFeatures,
|
||||
maxDomainsAllowedPerUser: 5,
|
||||
};
|
||||
});
|
||||
|
||||
await expect(getPlanFeaturesByOwnerId("owner-3", context)).rejects.toThrow(
|
||||
"temporary plan failure"
|
||||
);
|
||||
await expect(
|
||||
getPlanFeaturesByOwnerId("owner-3", context)
|
||||
).resolves.toMatchObject({
|
||||
maxDomainsAllowedPerUser: 5,
|
||||
});
|
||||
expect(ownerPlanCalls).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { PlanFeatures } from "@webstudio-is/plans";
|
||||
import type { AppContext } from "./context.server";
|
||||
|
||||
type ProjectOwnerContext = Pick<AppContext, "postgrest">;
|
||||
type ProjectPlanContext = Pick<
|
||||
AppContext,
|
||||
"postgrest" | "getOwnerPlanFeatures"
|
||||
>;
|
||||
type OwnerPlanContext = Pick<AppContext, "getOwnerPlanFeatures">;
|
||||
|
||||
const projectOwnerIdCache = new WeakMap<object, Map<string, Promise<string>>>();
|
||||
const ownerPlanFeaturesCache = new WeakMap<
|
||||
object,
|
||||
Map<string, Promise<PlanFeatures>>
|
||||
>();
|
||||
|
||||
const getCachedProjectOwnerId = (
|
||||
context: ProjectOwnerContext,
|
||||
projectId: string
|
||||
) => {
|
||||
let cache = projectOwnerIdCache.get(context);
|
||||
if (cache === undefined) {
|
||||
cache = new Map();
|
||||
projectOwnerIdCache.set(context, cache);
|
||||
}
|
||||
|
||||
let promise = cache.get(projectId);
|
||||
if (promise !== undefined) {
|
||||
return promise;
|
||||
}
|
||||
|
||||
promise = (async () => {
|
||||
const projectResult = await context.postgrest.client
|
||||
.from("Project")
|
||||
.select("userId")
|
||||
.eq("id", projectId)
|
||||
.eq("isDeleted", false)
|
||||
.single();
|
||||
|
||||
if (projectResult.error) {
|
||||
throw projectResult.error;
|
||||
}
|
||||
|
||||
if (projectResult.data.userId === null) {
|
||||
throw new Error("Project must have project userId defined");
|
||||
}
|
||||
|
||||
return projectResult.data.userId;
|
||||
})();
|
||||
|
||||
promise.catch(() => {
|
||||
if (cache.get(projectId) === promise) {
|
||||
cache.delete(projectId);
|
||||
}
|
||||
});
|
||||
cache.set(projectId, promise);
|
||||
return promise;
|
||||
};
|
||||
|
||||
export const getProjectOwnerId = async (
|
||||
projectId: string,
|
||||
context: ProjectOwnerContext
|
||||
): Promise<string> => {
|
||||
return await getCachedProjectOwnerId(context, projectId);
|
||||
};
|
||||
|
||||
export const getPlanFeaturesByOwnerId = async (
|
||||
ownerId: string,
|
||||
context: OwnerPlanContext
|
||||
): Promise<PlanFeatures> => {
|
||||
let cache = ownerPlanFeaturesCache.get(context);
|
||||
if (cache === undefined) {
|
||||
cache = new Map();
|
||||
ownerPlanFeaturesCache.set(context, cache);
|
||||
}
|
||||
|
||||
let promise = cache.get(ownerId);
|
||||
if (promise !== undefined) {
|
||||
return await promise;
|
||||
}
|
||||
|
||||
promise = context.getOwnerPlanFeatures(ownerId);
|
||||
|
||||
promise.catch(() => {
|
||||
if (cache.get(ownerId) === promise) {
|
||||
cache.delete(ownerId);
|
||||
}
|
||||
});
|
||||
cache.set(ownerId, promise);
|
||||
return await promise;
|
||||
};
|
||||
|
||||
export const getProjectPlanFeatures = async (
|
||||
projectId: string,
|
||||
context: ProjectPlanContext
|
||||
): Promise<PlanFeatures> => {
|
||||
const ownerId = await getProjectOwnerId(projectId, context);
|
||||
return await getPlanFeaturesByOwnerId(ownerId, context);
|
||||
};
|
||||
@@ -2,6 +2,11 @@ export type { SharedRouter } from "./shared/shared-router";
|
||||
export { createTrpcProxyServiceClient } from "./shared/client";
|
||||
|
||||
export type { AppContext } from "./context/context.server";
|
||||
export {
|
||||
getProjectOwnerId,
|
||||
getProjectPlanFeatures,
|
||||
getPlanFeaturesByOwnerId,
|
||||
} from "./context/project-plan.server";
|
||||
|
||||
export {
|
||||
AuthorizationError,
|
||||
|
||||
Reference in new issue
Block a user