diff --git a/.github/actions/vercel/action.yaml b/.github/actions/vercel/action.yaml index 9b0119f54184..b1f2c20e72f8 100644 --- a/.github/actions/vercel/action.yaml +++ b/.github/actions/vercel/action.yaml @@ -76,7 +76,11 @@ runs: export GITHUB_SHA=${{ inputs.sha }} export GITHUB_REF_NAME=${{ inputs.ref-name }} - pnpx vercel build + if [ "${{ inputs.environment }}" = "production" ]; then + pnpx vercel build --prod + else + pnpx vercel build + fi shell: bash - name: Patch @@ -103,10 +107,19 @@ runs: - name: Deploy id: deploy run: | - pnpx vercel deploy \ - --prebuilt \ - --token ${{ inputs.vercel-token }} \ - 2> >(tee info.txt >&2) | tee domain.txt + if [ "${{ inputs.environment }}" = "production" ]; then + pnpx vercel deploy \ + --prebuilt \ + --prod \ + --skip-domain \ + --token ${{ inputs.vercel-token }} \ + 2> >(tee info.txt >&2) | tee domain.txt + else + pnpx vercel deploy \ + --prebuilt \ + --token ${{ inputs.vercel-token }} \ + 2> >(tee info.txt >&2) | tee domain.txt + fi echo "domain=$(cat ./domain.txt)" >> $GITHUB_OUTPUT echo "inspect-url=$(cat info.txt | grep 'Inspect:' | awk '{print $2}')" >> $GITHUB_OUTPUT @@ -114,6 +127,7 @@ runs: shell: bash - name: Set Alias + if: ${{ inputs.environment != 'production' }} id: alias run: | ALIAS="${{ steps.branch.outputs.value }}" diff --git a/.github/workflows/vercel-deploy-staging.yml b/.github/workflows/vercel-deploy-staging.yml index a96527e72bfb..51e6d23bf227 100644 --- a/.github/workflows/vercel-deploy-staging.yml +++ b/.github/workflows/vercel-deploy-staging.yml @@ -87,6 +87,18 @@ jobs: sha: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }} environment: ${{ matrix.environment }} + - uses: ./.github/actions/vercel + if: matrix.environment == 'staging' + id: vercel-prod + name: Deploy to Vercel + with: + vercel-token: ${{ secrets.VERCEL_TOKEN }} + vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} + vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} + ref-name: ${{ github.ref_name }} + sha: ${{ github.sha }} + environment: "production" + - name: Debug Vercel Outputs run: | echo "domain=${{ steps.vercel.outputs.domain }}" @@ -99,6 +111,13 @@ jobs: description: "[${{ matrix.environment }}] Vercel logs" url: "${{ steps.vercel.outputs.inspect-url }}" + - uses: ./.github/actions/add-status + if: matrix.environment == 'staging' + with: + title: "⏰ [${{ matrix.environment }}] Vercel Production Inspection" + description: "[${{ matrix.environment }}] Vercel production logs" + url: "${{ steps.vercel-prod.outputs.inspect-url }}" + - uses: ./.github/actions/add-status with: title: "⭐ [${{ matrix.environment }}] Apps Webstudio URL" diff --git a/apps/builder/app/dashboard/workspace/manage-members-dialog.test.ts b/apps/builder/app/dashboard/workspace/manage-members-dialog.test.ts index 0b1278d361cf..b0d16e8a4e89 100644 --- a/apps/builder/app/dashboard/workspace/manage-members-dialog.test.ts +++ b/apps/builder/app/dashboard/workspace/manage-members-dialog.test.ts @@ -178,48 +178,4 @@ describe("computeAvailableSeats", () => { computeAvailableSeats(makeData({ maxSeats: 1, members }), optimistic) ).toBe(-2); }); - - test("confirmedMaxSeats overrides stale server maxSeats when higher", () => { - // Scenario: user confirmed paying for 2 extra seats but webhook hasn't fired. - // Server still returns maxSeats=3, but confirmedMaxSeats=5. - const members = [ - { - userId: "u1", - email: "a@example.com", - relation: "editors" as const, - createdAt: "", - username: "", - }, - ]; - const pendingInvites = [ - { - notificationId: "n1", - recipientId: "r1", - email: "b@example.com", - relation: "editors" as const, - createdAt: "", - }, - { - notificationId: "n2", - recipientId: "r2", - email: "c@example.com", - relation: "editors" as const, - createdAt: "", - }, - ]; - // maxSeats=3 (stale), but 1 member + 2 pending = 3 occupied → would be 0 available. - // confirmedMaxSeats=5 → effectiveMax=5, available = 5-1-2 = 2. - expect( - computeAvailableSeats( - makeData({ maxSeats: 3, members, pendingInvites }), - [], - 5 - ) - ).toBe(2); - }); - - test("confirmedMaxSeats is ignored when server maxSeats is already higher", () => { - // Webhook fired: server returns maxSeats=5, confirmedMaxSeats=4 (stale override). - expect(computeAvailableSeats(makeData({ maxSeats: 5 }), [], 4)).toBe(5); - }); }); diff --git a/apps/builder/app/dashboard/workspace/manage-members-dialog.tsx b/apps/builder/app/dashboard/workspace/manage-members-dialog.tsx index f1809ca4cf5b..b6cd61e6ee21 100644 --- a/apps/builder/app/dashboard/workspace/manage-members-dialog.tsx +++ b/apps/builder/app/dashboard/workspace/manage-members-dialog.tsx @@ -19,6 +19,7 @@ import { ScrollAreaNative, Select, Tooltip, + PanelBanner, css, theme, } from "@webstudio-is/design-system"; @@ -254,18 +255,11 @@ const computeAvailableSeats = ( { success: true } >["data"] | undefined, - optimisticPending: OptimisticPendingInvite[], - // Optimistic upper bound for maxSeats set when extra seats are confirmed, - // to avoid flickering while the Stripe webhook has not yet updated TransactionLog. - confirmedMaxSeats?: number + optimisticPending: OptimisticPendingInvite[] ): number | undefined => { if (membersData === undefined) { return; } - const effectiveMaxSeats = - confirmedMaxSeats !== undefined - ? Math.max(membersData.maxSeats, confirmedMaxSeats) - : membersData.maxSeats; const knownEmails = new Set([ membersData.owner.email, ...membersData.members.map((m) => m.email ?? ""), @@ -275,7 +269,7 @@ const computeAvailableSeats = ( (o) => !knownEmails.has(o.email) ).length; return ( - effectiveMaxSeats - + membersData.maxSeats - membersData.members.length - membersData.pendingInvites.length - extraCount @@ -435,10 +429,6 @@ export const ManageMembersDialog = ({ relation: Role; extraSeats: number; }>(); - // Optimistic maxSeats ceiling: set when the user confirms extra-seat charges so - // the footer and confirmation gate stay correct while the Stripe webhook is in - // flight (TransactionLog not yet updated). - const [confirmedMaxSeats, setConfirmedMaxSeats] = useState(); const formRef = useRef(null); const { load, data } = trpcClient.workspace.listMembers.useQuery(); @@ -453,22 +443,23 @@ export const ManageMembersDialog = ({ } }, [isOpen, isOwner, load, workspace.id]); - // Clear the optimistic override once the server reflects the paid seats. - useEffect(() => { - if ( - membersData !== undefined && - confirmedMaxSeats !== undefined && - membersData.maxSeats >= confirmedMaxSeats - ) { - setConfirmedMaxSeats(undefined); - } - }, [membersData, confirmedMaxSeats]); + const availableSeats = computeAvailableSeats(membersData, optimisticPending); - const availableSeats = computeAvailableSeats( - membersData, - optimisticPending, - confirmedMaxSeats - ); + const syncSeatsMutation = trpcClient.workspace.syncSeats.useMutation(); + + const overCapacity = + availableSeats !== undefined && availableSeats < 0 ? -availableSeats : 0; + + const handleSyncSeats = () => { + syncSeatsMutation.send({ workspaceId: workspace.id }, (result) => { + if (result && "error" in result) { + setErrors([result.error]); + return; + } + handleRefresh(); + revalidator.revalidate(); + }); + }; const performInvite = async (emails: string[], relation: Role) => { setErrors(undefined); @@ -536,14 +527,6 @@ export const ManageMembersDialog = ({ onConfirm={async () => { const confirm = pendingConfirm; setPendingConfirm(undefined); - // Optimistically raise confirmedMaxSeats so the footer and the - // confirmation gate reflect the payment before the webhook fires. - setConfirmedMaxSeats((prev) => - Math.max( - prev ?? 0, - (membersData?.maxSeats ?? 0) + confirm.extraSeats - ) - ); await performInvite(confirm.emails, confirm.relation); }} /> @@ -554,7 +537,6 @@ export const ManageMembersDialog = ({ onOpenChange(open); if (open === false) { setErrors(undefined); - setConfirmedMaxSeats(undefined); } }} > @@ -599,6 +581,31 @@ export const ManageMembersDialog = ({ )} + {isOwner && overCapacity > 0 && ( + + + + {`Your workspace has ${overCapacity} more member${overCapacity === 1 ? "" : "s"} than your plan covers. Non-owner members won't be able to access the workspace until this is resolved.`} + + + + + {`or remove ${overCapacity} member${overCapacity === 1 ? "" : "s"}`} + + + + + )} { }, []); useEffect(() => { - if (search !== "") { - setSetting("lastDashboardSearch", search); - } + setSetting("lastDashboardSearch", search); }, [search]); // `useLoaderData` wraps the return in `JsonifyObject` which turns diff --git a/apps/builder/app/services/workspace-router.server.ts b/apps/builder/app/services/workspace-router.server.ts index 4eb261b68c5a..5e548d829359 100644 --- a/apps/builder/app/services/workspace-router.server.ts +++ b/apps/builder/app/services/workspace-router.server.ts @@ -6,7 +6,7 @@ import { } 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"; -import { getPaidSeats } from "@webstudio-is/plans/index.server"; +import { getExtraPaidSeats } from "@webstudio-is/plans/index.server"; import env from "~/env/env.server"; const Name = z.string().min(2).max(100); @@ -299,20 +299,37 @@ export const workspaceRouter = router({ } }), + syncSeats: procedure + .input(z.object({ workspaceId: z.string() })) + .mutation(async ({ input, ctx }) => { + try { + if (ctx.planFeatures.maxWorkspaces <= 1) { + throw new Error("Upgrade your plan to manage workspace seats."); + } + await syncOwnerSeats(input.workspaceId, ctx); + return { success: true as const }; + } catch (error) { + return createErrorResponse(error); + } + }), + listMembers: procedure .input(z.object({ workspaceId: z.string() })) .query(async ({ input, ctx }) => { try { const members = await workspaceApi.listMembers(input, ctx); - const paidSeats = await getPaidSeats(members.owner.userId, ctx); + const extraPaidSeats = await getExtraPaidSeats( + members.owner.userId, + ctx + ); return { success: true as const, data: { ...members, // seatsIncluded = seats covered by the Team plan. - // paidSeats = extra seats from the Seats subscription. + // extraPaidSeats = extra seats from the Seats subscription. // Total capacity = included + extras. - maxSeats: ctx.planFeatures.seatsIncluded + (paidSeats ?? 0), + maxSeats: ctx.planFeatures.seatsIncluded + (extraPaidSeats ?? 0), }, }; } catch (error) { diff --git a/packages/css-engine/src/core/to-value.test.ts b/packages/css-engine/src/core/to-value.test.ts index 8319ed53f59b..cf84b7f5e193 100644 --- a/packages/css-engine/src/core/to-value.test.ts +++ b/packages/css-engine/src/core/to-value.test.ts @@ -446,7 +446,6 @@ describe("Convert WS CSS Values to native CSS strings", () => { expect(value).toBe(expected); }); } - }); test("color in tuple", () => { const value = toValue({ diff --git a/packages/css-engine/src/core/to-value.ts b/packages/css-engine/src/core/to-value.ts index cd4dec85b07e..16c83dc8661e 100644 --- a/packages/css-engine/src/core/to-value.ts +++ b/packages/css-engine/src/core/to-value.ts @@ -122,7 +122,7 @@ export const toValue = ( case "oklch": return `oklch(${c1} ${c2} ${c3} / ${alpha})`; // Fall back to color() function for less common color spaces. - // colorjs uses internal short names that differ from CSS predefined color space identifiers. + // Webstudio uses colorjs internal names; map to CSS predefined color space names. case "p3": return `color(display-p3 ${c1} ${c2} ${c3} / ${alpha})`; case "a98rgb": diff --git a/packages/plans/src/index.server.ts b/packages/plans/src/index.server.ts index 821442e44dad..7d95a04c1da9 100644 --- a/packages/plans/src/index.server.ts +++ b/packages/plans/src/index.server.ts @@ -1,7 +1,7 @@ export * from "./index"; export { getPlanInfo, - getPaidSeats, + getExtraPaidSeats, getAuthorizationOwnerId, parsePlansEnv, parseProductMeta, diff --git a/packages/plans/src/plan-client.server.ts b/packages/plans/src/plan-client.server.ts index 8c809a4d0a58..863b5b4ff12c 100644 --- a/packages/plans/src/plan-client.server.ts +++ b/packages/plans/src/plan-client.server.ts @@ -206,14 +206,30 @@ export const getPlanInfo = async ( }; /** - * Returns the Stripe subscription item quantity for the given user, derived - * from the latest customer.subscription.updated/created event in TransactionLog. - * Returns null when no subscription event exists yet (free plan, AppSumo, etc.). + * Returns the extra-seat subscription quantity for the given user, derived + * from the latest customer.subscription.updated/created event in TransactionLog + * whose product is an extra-seat plan (maxSeatsPerWorkspace > 0, maxWorkspaces <= 1). + * + * Returns null when no matching event exists (free plan, no extra seats, etc.). */ -export const getPaidSeats = async ( +export const getExtraPaidSeats = async ( userId: string, context: { postgrest: PostgrestContext } ): Promise => { + const productResult = await context.postgrest.client + .from("Product") + .select("id") + .eq("name", "Seats"); + + if (productResult.error) { + throw productResult.error; + } + + const seatProductIds = (productResult.data ?? []).map((p) => p.id); + if (seatProductIds.length === 0) { + return null; + } + const result = await context.postgrest.client .from("TransactionLog") .select("eventData") @@ -222,6 +238,7 @@ export const getPaidSeats = async ( "customer.subscription.updated", "customer.subscription.created", ]) + .in("productId", seatProductIds) .order("eventCreated", { ascending: false }) .limit(1) .maybeSingle(); diff --git a/packages/project/src/db/workspace.ts b/packages/project/src/db/workspace.ts index e16c9db81548..527485041232 100644 --- a/packages/project/src/db/workspace.ts +++ b/packages/project/src/db/workspace.ts @@ -11,7 +11,7 @@ import { NOTIFICATION_TTL_MS, } from "../shared/notification-schema"; import { create as createNotification } from "./notification"; -import { getPaidSeats } from "@webstudio-is/plans/index.server"; +import { getExtraPaidSeats } from "@webstudio-is/plans/index.server"; export type Workspace = Database["public"]["Tables"]["Workspace"]["Row"]; @@ -253,7 +253,7 @@ export const findMany = async (userId: string, context: AppContext) => { } const [memberCount, extraSeats] = await Promise.all([ countAllMembers(ownerId, context), - getPaidSeats(ownerId, context), + getExtraPaidSeats(ownerId, context), ]); // seatsIncluded is the number of non-owner member slots included in // the plan. The owner is not counted — consistent with the billing