diff --git a/apps/webapp/app/components/logs/LogsSearchInput.tsx b/apps/webapp/app/components/primitives/SearchInput.tsx similarity index 78% rename from apps/webapp/app/components/logs/LogsSearchInput.tsx rename to apps/webapp/app/components/primitives/SearchInput.tsx index 44f4d130185..ea9156839a3 100644 --- a/apps/webapp/app/components/logs/LogsSearchInput.tsx +++ b/apps/webapp/app/components/primitives/SearchInput.tsx @@ -6,22 +6,25 @@ import { ShortcutKey } from "~/components/primitives/ShortcutKey"; import { useSearchParams } from "~/hooks/useSearchParam"; import { cn } from "~/utils/cn"; -export type LogsSearchInputProps = { +export type SearchInputProps = { placeholder?: string; + /** Additional URL params to reset when searching or clearing (e.g. pagination). Defaults to ["cursor", "direction"]. */ + resetParams?: string[]; }; -export function LogsSearchInput({ placeholder = "Search logs…" }: LogsSearchInputProps) { +export function SearchInput({ + placeholder = "Search logs…", + resetParams = ["cursor", "direction"], +}: SearchInputProps) { const inputRef = useRef(null); const { value, replace, del } = useSearchParams(); - // Get initial search value from URL const initialSearch = value("search") ?? ""; const [text, setText] = useState(initialSearch); const [isFocused, setIsFocused] = useState(false); - // Update text when URL search param changes (only when not focused to avoid overwriting user input) useEffect(() => { const urlSearch = value("search") ?? ""; if (urlSearch !== text && !isFocused) { @@ -30,21 +33,22 @@ export function LogsSearchInput({ placeholder = "Search logs…" }: LogsSearchIn }, [value, text, isFocused]); const handleSubmit = useCallback(() => { + const resetValues = Object.fromEntries(resetParams.map((p) => [p, undefined])); if (text.trim()) { - replace({ search: text.trim() }); + replace({ search: text.trim(), ...resetValues }); } else { - del("search"); + del(["search", ...resetParams]); } - }, [text, replace, del]); + }, [text, replace, del, resetParams]); const handleClear = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setText(""); - del(["search", "cursor", "direction"]); + del(["search", ...resetParams]); }, - [del] + [del, resetParams] ); return ( @@ -82,12 +86,12 @@ export function LogsSearchInput({ placeholder = "Search logs…" }: LogsSearchIn icon={} accessory={ text.length > 0 ? ( -
- +
+ } + /> + } + panelClassName="mb-4" + > + + You've used all {limits.limit} of your available team members. Purchase extra seats + to add more. + + + ) : ( + + Upgrade + + } + panelClassName="mb-4" + > + + You've used all {limits.limit} of your available team members. Upgrade your plan to + add more. + + + ))}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index f356ee6c81a..c7d54d1842e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -1,23 +1,17 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import { - ArrowRightIcon, - ArrowUpCircleIcon, - CheckIcon, - MagnifyingGlassIcon, - PlusIcon, -} from "@heroicons/react/20/solid"; +import { ArrowUpCircleIcon, CheckIcon, EnvelopeIcon, PlusIcon } from "@heroicons/react/20/solid"; import { BookOpenIcon } from "@heroicons/react/24/solid"; import { DialogClose } from "@radix-ui/react-dialog"; -import { Form, useActionData, useLocation, useSearchParams } from "@remix-run/react"; +import { Form, useActionData, useFetcher, useLocation, useSearchParams } from "@remix-run/react"; import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { GitMeta } from "@trigger.dev/core/v3"; +import { GitMeta, tryCatch } from "@trigger.dev/core/v3"; import { useCallback, useEffect, useState } from "react"; +import { SearchInput } from "~/components/primitives/SearchInput"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; import { BranchesNoBranchableEnvironment, BranchesNoBranches } from "~/components/BlankStatePanels"; -import { Feedback } from "~/components/Feedback"; import { GitMetadata } from "~/components/GitMetadata"; import { V4Title } from "~/components/V4Badge"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; @@ -41,12 +35,14 @@ import { Header3 } from "~/components/primitives/Headers"; import { Hint } from "~/components/primitives/Hint"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; +import { InputNumberStepper } from "~/components/primitives/InputNumberStepper"; import { Label } from "~/components/primitives/Label"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { PaginationControls } from "~/components/primitives/Pagination"; import { Paragraph } from "~/components/primitives/Paragraph"; import { PopoverMenuItem } from "~/components/primitives/Popover"; import * as Property from "~/components/primitives/PropertyTable"; +import { SpinnerWhite } from "~/components/primitives/Spinner"; import { Switch } from "~/components/primitives/Switch"; import { Table, @@ -62,16 +58,26 @@ import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip" import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; -import { useThrottle } from "~/hooks/useThrottle"; + +import { findProjectBySlug } from "~/models/project.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { BranchesPresenter } from "~/presenters/v3/BranchesPresenter.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { UpsertBranchService } from "~/services/upsertBranch.server"; import { cn } from "~/utils/cn"; -import { branchesPath, docsPath, ProjectParamSchema, v3BillingPath } from "~/utils/pathBuilder"; +import { + branchesPath, + docsPath, + EnvironmentParamSchema, + ProjectParamSchema, + v3BillingPath, +} from "~/utils/pathBuilder"; +import { formatCurrency, formatNumber } from "~/utils/numberFormatter"; +import { SetBranchesAddOnService } from "~/v3/services/setBranchesAddOn.server"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { ArchiveButton } from "../resources.branches.archive"; +import { IconArrowBearRight2 } from "@tabler/icons-react"; export const BranchesOptions = z.object({ search: z.string().optional(), @@ -119,10 +125,68 @@ export const schema = CreateBranchOptions.and( }) ); -export async function action({ request }: ActionFunctionArgs) { +const PurchaseSchema = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("purchase"), + amount: z.coerce.number().int("Must be a whole number").min(0, "Amount must be 0 or more"), + }), + z.object({ + action: z.literal("quota-increase"), + amount: z.coerce + .number() + .int("Must be a whole number") + .min(1, "Amount must be greater than 0"), + }), +]); + +export async function action({ request, params }: ActionFunctionArgs) { const userId = await requireUserId(request); const formData = await request.formData(); + const formType = formData.get("_formType"); + + if (formType === "purchase-branches") { + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + const redirectPath = branchesPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam } + ); + + if (!project) { + throw redirectWithErrorMessage(redirectPath, request, "Project not found"); + } + + const submission = parse(formData, { schema: PurchaseSchema }); + + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } + + const service = new SetBranchesAddOnService(); + const [error, result] = await tryCatch( + service.call({ + userId, + organizationId: project.organizationId, + action: submission.value.action, + amount: submission.value.amount, + }) + ); + + if (error) { + submission.error.amount = [error instanceof Error ? error.message : "Unknown error"]; + return json(submission); + } + + if (!result.success) { + submission.error.amount = [result.error]; + return json(submission); + } + + return json({ ok: true } as const); + } + const submission = parse(formData, { schema }); if (!submission.value) { @@ -165,6 +229,11 @@ export default function Page() { currentPage, totalPages, hasBranches, + canPurchaseBranches, + extraBranches, + branchPricing, + maxBranchQuota, + planBranchLimit, } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); @@ -177,6 +246,8 @@ export default function Page() { !plan.v3Subscription.plan.limits.branches.canExceed; const canUpgrade = plan?.v3Subscription?.plan && !plan.v3Subscription.plan.limits.branches.canExceed; + const atBranchLimit = limits.used >= limits.limit; + const usageRatio = limits.limit > 0 ? Math.min(limits.used / limits.limit, 1) : 0; if (!branchableEnvironment) { return ( @@ -218,7 +289,15 @@ export default function Page() { {limits.isAtLimit ? ( - + ) : ( - New branch + New branch… } parentEnvironment={branchableEnvironment} @@ -324,7 +403,15 @@ export default function Page() { isSticky hiddenButtons={ isSelected ? null : ( - + + Switch to branch + ) } popoverContent={ @@ -333,8 +420,8 @@ export default function Page() { {isSelected ? null : ( )} @@ -376,20 +463,20 @@ export default function Page() { />
} - content={`${Math.round((limits.used / limits.limit) * 100)}%`} + content={`${Math.round(usageRatio * 100)}%`} />
{requiresUpgrade ? ( @@ -399,28 +486,36 @@ export default function Page() { ) : (
- + You've used {limits.used}/{limits.limit} of your branches
)} - {canUpgrade ? ( - - Upgrade - - ) : ( - Request more} - defaultValue="help" + {canPurchaseBranches && branchPricing ? ( + - )} + ) : canUpgrade ? ( +
+ + Upgrade plan for more Preview Branches + + + Upgrade + +
+ ) : null}
@@ -434,42 +529,23 @@ export default function Page() { export function BranchFilters() { const [searchParams, setSearchParams] = useSearchParams(); - const { search, showArchived, page } = BranchesOptions.parse( - Object.fromEntries(searchParams.entries()) - ); + const { showArchived } = BranchesOptions.parse(Object.fromEntries(searchParams.entries())); - const handleFilterChange = useCallback((filterType: string, value: string | undefined) => { + const handleArchivedChange = useCallback((checked: boolean) => { setSearchParams((s) => { - if (value) { - searchParams.set(filterType, value); + if (checked) { + s.set("showArchived", "true"); } else { - searchParams.delete(filterType); + s.delete("showArchived"); } - searchParams.delete("page"); - return searchParams; + s.delete("page"); + return s; }); }, []); - const handleArchivedChange = useCallback((checked: boolean) => { - handleFilterChange("showArchived", checked ? "true" : undefined); - }, []); - - const handleSearchChange = useThrottle((value: string) => { - handleFilterChange("search", value.length === 0 ? undefined : value); - }, 300); - return ( -
- handleSearchChange(e.target.value)} - /> - +
+ + Purchase more… + + } + /> + ); + } + return ( @@ -517,18 +625,270 @@ function UpgradePanel({ Upgrade - ) : ( - Request more} - defaultValue="help" - /> - )} + ) : null} ); } +function PurchaseBranchesModal({ + branchPricing, + extraBranches, + activeBranches, + maxQuota, + planBranchLimit, + triggerButton, +}: { + branchPricing: { + stepSize: number; + centsPerStep: number; + }; + extraBranches: number; + activeBranches: number; + maxQuota: number; + planBranchLimit: number; + triggerButton?: React.ReactNode; +}) { + const fetcher = useFetcher(); + const lastSubmission = + fetcher.data && typeof fetcher.data === "object" && "intent" in fetcher.data + ? fetcher.data + : undefined; + const [form, { amount }] = useForm({ + id: "purchase-branches", + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema: PurchaseSchema }); + }, + shouldRevalidate: "onSubmit", + }); + + const [amountValue, setAmountValue] = useState(extraBranches); + useEffect(() => { + setAmountValue(extraBranches); + }, [extraBranches]); + const isLoading = fetcher.state !== "idle"; + + const [open, setOpen] = useState(false); + useEffect(() => { + const data = fetcher.data; + if (fetcher.state === "idle" && data !== null && typeof data === "object" && "ok" in data && data.ok) { + setOpen(false); + } + }, [fetcher.state, fetcher.data]); + + const state = updateBranchState({ + value: amountValue, + existingValue: extraBranches, + quota: maxQuota, + activeBranches, + planBranchLimit, + }); + const changeClassName = + state === "decrease" ? "text-error" : state === "increase" ? "text-success" : undefined; + + const pricePerBranch = branchPricing.centsPerStep / branchPricing.stepSize / 100; + const title = extraBranches === 0 ? "Purchase extra branches…" : "Add/remove extra branches…"; + + return ( + + + {triggerButton ?? ( + + )} + + + {title} + + +
+
+ + Purchase extra preview branches at {formatCurrency(pricePerBranch, false)}/month per + branch. Reducing the number of branches will take effect at the start of the next + billing cycle (1st of the month). + +
+
+ + + setAmountValue(Number(e.target.value))} + disabled={isLoading} + /> + + {amount.error ?? amount.initialError?.[""]?.[0]} + + {form.error} + +
+ {state === "need_to_archive" ? ( +
+ + You need to archive{" "} + {formatNumber(activeBranches - (planBranchLimit + amountValue))} more{" "} + {activeBranches - (planBranchLimit + amountValue) === 1 ? "branch" : "branches"}{" "} + before you can reduce to this level. + +
+ ) : state === "above_quota" ? ( +
+ + Currently you can only have up to {maxQuota} extra preview branches. Send a + request below to lift your current limit. We'll get back to you soon. + +
+ ) : ( +
+
+ Summary + Total +
+
+ + {formatNumber(extraBranches)} current + extra + + + {formatCurrency(extraBranches * pricePerBranch, true)} + +
+
+ + ({extraBranches} {extraBranches === 1 ? "branch" : "branches"}) + + /mth +
+
+ + {state === "increase" ? "+" : null} + {formatNumber(amountValue - extraBranches)} + + + {state === "increase" ? "+" : null} + {formatCurrency((amountValue - extraBranches) * pricePerBranch, true)} + +
+
+ + ({Math.abs(amountValue - extraBranches)}{" "} + {Math.abs(amountValue - extraBranches) === 1 ? "branch" : "branches"} @{" "} + {formatCurrency(pricePerBranch, true)}/mth) + + /mth +
+
+ + {formatNumber(amountValue)} new total + + + {formatCurrency(amountValue * pricePerBranch, true)} + +
+
+ + ({amountValue} {amountValue === 1 ? "branch" : "branches"}) + + /mth +
+
+ )} +
+ + + + + ) : state === "decrease" || state === "need_to_archive" ? ( + <> + + + + ) : ( + <> + + + + ) + } + cancelButton={ + + + + } + /> +
+
+
+ ); +} + +function updateBranchState({ + value, + existingValue, + quota, + activeBranches, + planBranchLimit, +}: { + value: number; + existingValue: number; + quota: number; + activeBranches: number; + planBranchLimit: number; +}): "no_change" | "increase" | "decrease" | "above_quota" | "need_to_archive" { + if (value === existingValue) return "no_change"; + if (value < existingValue) { + const newTotalLimit = planBranchLimit + value; + if (activeBranches > newTotalLimit) { + return "need_to_archive"; + } + return "decrease"; + } + if (value > quota) return "above_quota"; + return "increase"; +} + export function NewBranchPanel({ button, parentEnvironment, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx index fba75290f00..2459a067902 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx @@ -14,7 +14,7 @@ import { } from "recharts"; import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; import { PageBody } from "~/components/layout/AppLayout"; -import { LogsSearchInput } from "~/components/logs/LogsSearchInput"; +import { SearchInput } from "~/components/primitives/SearchInput"; import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter"; import { Button } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; @@ -255,7 +255,7 @@ function FiltersBar({ maxPeriodDays={retentionLimitDays} labelName="Occurred" /> - + {hasFilters && ( + @@ -341,7 +470,7 @@ function LeaveTeamModal({ - + setOpen(false)}> @@ -355,12 +484,61 @@ function LeaveTeamModal({ ); } +const RESEND_COOLDOWN_SECONDS = 30; + +function initialCooldown(updatedAt: Date | string): number { + const elapsed = Math.floor((Date.now() - new Date(updatedAt).getTime()) / 1000); + const remaining = RESEND_COOLDOWN_SECONDS - elapsed; + return remaining > 0 ? remaining : 0; +} + function ResendButton({ invite }: { invite: Invite }) { + const navigation = useNavigation(); + const isSubmitting = + navigation.state === "submitting" && + navigation.formAction === resendInvitePath() && + navigation.formData?.get("inviteId") === invite.id; + const prevSubmitting = useRef(false); + const [cooldown, setCooldown] = useState(() => initialCooldown(invite.updatedAt)); + const intervalRef = useRef>(); + + useEffect(() => { + if (prevSubmitting.current && !isSubmitting) { + setCooldown(RESEND_COOLDOWN_SECONDS); + } + prevSubmitting.current = isSubmitting; + }, [isSubmitting]); + + const cooldownActive = cooldown > 0; + useEffect(() => { + if (!cooldownActive) return; + + intervalRef.current = setInterval(() => { + setCooldown((c) => { + if (c <= 1) { + clearInterval(intervalRef.current); + return 0; + } + return c - 1; + }); + }, 1000); + + return () => clearInterval(intervalRef.current); + }, [cooldownActive]); + + const isDisabled = isSubmitting || cooldown > 0; + return ( - ); @@ -373,12 +551,285 @@ function RevokeButton({ invite }: { invite: Invite }) {
-
+ )} + + + {title} + + +
+
+ + Purchase extra seats at {formatCurrency(pricePerSeat, true)}/month per seat. + Reducing seats will take effect at the start of your next billing cycle (on the 1st + of the month). + +
+
+ + + setAmountValue(Number(e.target.value))} + disabled={isLoading} + /> + + {amount.error ?? amount.initialError?.[""]?.[0]} + + {form.error} + +
+ {state === "need_to_remove_members" ? ( +
+ + You need to remove {formatNumber(usedSeats - (planSeatLimit + amountValue))}{" "} + {usedSeats - (planSeatLimit + amountValue) === 1 + ? "team member or pending invite" + : "team members or pending invites"}{" "} + before you can reduce to this level. + +
+ ) : state === "above_quota" ? ( +
+ + Currently you can only have up to {maxQuota} extra seats. Send a request below to + lift your current limit. We'll get back to you soon. + +
+ ) : ( +
+
+ Summary + Total +
+
+ + {formatNumber(extraSeats)} current + extra + + + {formatCurrency(extraSeats * pricePerSeat, true)} + +
+
+ + ({extraSeats} {extraSeats === 1 ? "seat" : "seats"}) + + /mth +
+
+ + {state === "increase" ? "+" : null} + {formatNumber(amountValue - extraSeats)} + + + {state === "increase" ? "+" : null} + {formatCurrency((amountValue - extraSeats) * pricePerSeat, true)} + +
+
+ + ({Math.abs(amountValue - extraSeats)}{" "} + {Math.abs(amountValue - extraSeats) === 1 ? "seat" : "seats"} @{" "} + {formatCurrency(pricePerSeat, true)}/mth) + + /mth +
+
+ + {formatNumber(amountValue)} new total + + + {formatCurrency(amountValue * pricePerSeat, true)} + +
+
+ + ({amountValue} {amountValue === 1 ? "seat" : "seats"}) + + /mth +
+
+ )} +
+ + + + + ) : state === "decrease" || state === "need_to_remove_members" ? ( + <> + + + + ) : ( + <> + + + + ) + } + cancelButton={ + + + + } + /> +
+
+ + ); +} + +function updateSeatState({ + value, + existingValue, + quota, + usedSeats, + planSeatLimit, +}: { + value: number; + existingValue: number; + quota: number; + usedSeats: number; + planSeatLimit: number; +}): "no_change" | "increase" | "decrease" | "above_quota" | "need_to_remove_members" { + if (value === existingValue) return "no_change"; + if (value < existingValue) { + const newTotalLimit = planSeatLimit + value; + if (usedSeats > newTotalLimit) { + return "need_to_remove_members"; + } + return "decrease"; + } + if (value > quota) return "above_quota"; + return "increase"; +} diff --git a/apps/webapp/app/routes/resources.branches.archive.tsx b/apps/webapp/app/routes/resources.branches.archive.tsx index 57ba061bf01..6421e5588e1 100644 --- a/apps/webapp/app/routes/resources.branches.archive.tsx +++ b/apps/webapp/app/routes/resources.branches.archive.tsx @@ -120,7 +120,7 @@ export function ArchiveButton({ } cancelButton={ - + } /> diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 2fc4c8c5c1f..a27c512ef43 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -34,10 +34,7 @@ function initializeClient() { url: process.env.BILLING_API_URL, apiKey: process.env.BILLING_API_KEY, }); - console.log(`🤑 Billing client initialized: ${process.env.BILLING_API_URL}`); return client; - } else { - console.log(`🤑 Billing client not initialized`); } } @@ -409,6 +406,38 @@ export async function setConcurrencyAddOn(organizationId: string, amount: number } } +export async function setSeatsAddOn(organizationId: string, amount: number) { + if (!client) return undefined; + + try { + const result = await client.setAddOn(organizationId, { type: "seats", amount }); + if (!result.success) { + logger.error("Error setting seats add on - no success", { error: result.error }); + return undefined; + } + return result; + } catch (e) { + logger.error("Error setting seats add on - caught error", { error: e }); + return undefined; + } +} + +export async function setBranchesAddOn(organizationId: string, amount: number) { + if (!client) return undefined; + + try { + const result = await client.setAddOn(organizationId, { type: "branches", amount }); + if (!result.success) { + logger.error("Error setting branches add on - no success", { error: result.error }); + return undefined; + } + return result; + } catch (e) { + logger.error("Error setting branches add on - caught error", { error: e }); + return undefined; + } +} + export async function getUsage(organizationId: string, { from, to }: { from: Date; to: Date }) { if (!client) return undefined; diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts index a11fdc350ab..3b4e18a58ea 100644 --- a/apps/webapp/app/services/upsertBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -5,7 +5,7 @@ import { createApiKeyForEnv, createPkApiKeyForEnv } from "~/models/api-key.serve import { type CreateBranchOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route"; import { isValidGitBranchName, sanitizeBranchName } from "~/v3/gitBranch"; import { logger } from "./logger.server"; -import { getLimit } from "./platform.v3.server"; +import { getCurrentPlan, getLimit } from "./platform.v3.server"; export class UpsertBranchService { #prismaClient: PrismaClient; @@ -177,7 +177,10 @@ export async function checkBranchLimit( const count = newBranchName ? usedEnvs.filter((env) => env.branchName !== newBranchName).length : usedEnvs.length; - const limit = await getLimit(organizationId, "branches", 100_000_000); + const baseLimit = await getLimit(organizationId, "branches", 100_000_000); + const currentPlan = await getCurrentPlan(organizationId); + const purchasedBranches = currentPlan?.v3Subscription?.addOns?.branches?.purchased ?? 0; + const limit = baseLimit + purchasedBranches; return { used: count, diff --git a/apps/webapp/app/v3/services/setBranchesAddOn.server.ts b/apps/webapp/app/v3/services/setBranchesAddOn.server.ts new file mode 100644 index 00000000000..55f4f5817d8 --- /dev/null +++ b/apps/webapp/app/v3/services/setBranchesAddOn.server.ts @@ -0,0 +1,100 @@ +import { BaseService } from "./baseService.server"; +import { tryCatch } from "@trigger.dev/core/utils"; +import { setBranchesAddOn } from "~/services/platform.v3.server"; +import assertNever from "assert-never"; +import { sendToPlain } from "~/utils/plain.server"; +import { uiComponent } from "@team-plain/typescript-sdk"; + +type Input = { + userId: string; + organizationId: string; + action: "purchase" | "quota-increase"; + amount: number; +}; + +type Result = + | { + success: true; + } + | { + success: false; + error: string; + }; + +export class SetBranchesAddOnService extends BaseService { + async call({ userId, organizationId, action, amount }: Input): Promise { + switch (action) { + case "purchase": { + const result = await setBranchesAddOn(organizationId, amount); + if (!result) { + return { + success: false, + error: "Failed to update preview branches", + }; + } + + switch (result.result) { + case "success": { + return { success: true }; + } + case "error": { + return { success: false, error: result.error }; + } + case "max_quota_reached": { + return { + success: false, + error: `You can't purchase more than ${result.maxQuota} preview branches without requesting an increase.`, + }; + } + default: { + return { + success: false, + error: "Failed to update preview branches, unknown result.", + }; + } + } + } + case "quota-increase": { + const user = await this._replica.user.findFirst({ + where: { id: userId }, + }); + + if (!user) { + return { success: false, error: "No matching user found." }; + } + + const organization = await this._replica.organization.findFirst({ + select: { title: true }, + where: { id: organizationId }, + }); + + const [error] = await tryCatch( + sendToPlain({ + userId, + email: user.email, + name: user.name ?? user.displayName ?? user.email, + title: `Preview branches quota request: ${amount}`, + components: [ + uiComponent.text({ + text: `Org: ${organization?.title} (${organizationId})`, + }), + uiComponent.divider({ spacingSize: "M" }), + uiComponent.text({ + text: `Total preview branches requested: ${amount}`, + }), + ], + }) + ); + + if (error) { + return { success: false, error: error.message }; + } + + return { success: true }; + } + default: { + assertNever(action); + } + } + } +} diff --git a/apps/webapp/app/v3/services/setSeatsAddOn.server.ts b/apps/webapp/app/v3/services/setSeatsAddOn.server.ts new file mode 100644 index 00000000000..b9159c2ee7c --- /dev/null +++ b/apps/webapp/app/v3/services/setSeatsAddOn.server.ts @@ -0,0 +1,100 @@ +import { BaseService } from "./baseService.server"; +import { tryCatch } from "@trigger.dev/core/utils"; +import { setSeatsAddOn } from "~/services/platform.v3.server"; +import assertNever from "assert-never"; +import { sendToPlain } from "~/utils/plain.server"; +import { uiComponent } from "@team-plain/typescript-sdk"; + +type Input = { + userId: string; + organizationId: string; + action: "purchase" | "quota-increase"; + amount: number; +}; + +type Result = + | { + success: true; + } + | { + success: false; + error: string; + }; + +export class SetSeatsAddOnService extends BaseService { + async call({ userId, organizationId, action, amount }: Input): Promise { + switch (action) { + case "purchase": { + const result = await setSeatsAddOn(organizationId, amount); + if (!result) { + return { + success: false, + error: "Failed to update seats", + }; + } + + switch (result.result) { + case "success": { + return { success: true }; + } + case "error": { + return { success: false, error: result.error }; + } + case "max_quota_reached": { + return { + success: false, + error: `You can't purchase more than ${result.maxQuota} seats without requesting an increase.`, + }; + } + default: { + return { + success: false, + error: "Failed to update seats, unknown result.", + }; + } + } + } + case "quota-increase": { + const user = await this._replica.user.findFirst({ + where: { id: userId }, + }); + + if (!user) { + return { success: false, error: "No matching user found." }; + } + + const organization = await this._replica.organization.findFirst({ + select: { title: true }, + where: { id: organizationId }, + }); + + const [error] = await tryCatch( + sendToPlain({ + userId, + email: user.email, + name: user.name ?? user.displayName ?? user.email, + title: `Seats quota request: ${amount}`, + components: [ + uiComponent.text({ + text: `Org: ${organization?.title} (${organizationId})`, + }), + uiComponent.divider({ spacingSize: "M" }), + uiComponent.text({ + text: `Total seats requested: ${amount}`, + }), + ], + }) + ); + + if (error) { + return { success: false, error: error.message }; + } + + return { success: true }; + } + default: { + assertNever(action); + } + } + } +} diff --git a/apps/webapp/package.json b/apps/webapp/package.json index b7f8cacebb0..139a0ce2d0d 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -122,7 +122,7 @@ "@trigger.dev/core": "workspace:*", "@trigger.dev/database": "workspace:*", "@trigger.dev/otlp-importer": "workspace:*", - "@trigger.dev/platform": "1.0.23", + "@trigger.dev/platform": "1.0.24", "@trigger.dev/redis-worker": "workspace:*", "@trigger.dev/sdk": "workspace:*", "@types/pg": "8.6.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83e28308337..4369f56bb29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -501,8 +501,8 @@ importers: specifier: workspace:* version: link:../../internal-packages/otlp-importer '@trigger.dev/platform': - specifier: 1.0.23 - version: 1.0.23 + specifier: 1.0.24 + version: 1.0.24 '@trigger.dev/redis-worker': specifier: workspace:* version: link:../../packages/redis-worker @@ -1104,7 +1104,7 @@ importers: version: 18.3.1 react-email: specifier: ^2.1.1 - version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0) + version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0) resend: specifier: ^3.2.0 version: 3.2.0 @@ -10514,8 +10514,8 @@ packages: react: ^18.2.0 react-dom: 18.2.0 - '@trigger.dev/platform@1.0.23': - resolution: {integrity: sha512-/fHMOKHdqRv6t70h0weUorOeVOkX+8WGWwPlzdq+uGDqkf8ZrcwBDuBSyoG9KkyvIsA8Tw64zVbWK94CbVlznw==} + '@trigger.dev/platform@1.0.24': + resolution: {integrity: sha512-dg9/QWyNBCctbGhzr9U2vImUdJNR+2FqTHVTJfgrSq12BIBpwAfUiSfel1S/beEGltBKhi7KXkBAsk+9cFPPzQ==} '@types/acorn@4.0.6': resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} @@ -23155,7 +23155,7 @@ snapshots: '@epic-web/test-server@0.1.0(bufferutil@4.0.9)': dependencies: '@hono/node-server': 1.12.2(hono@4.5.11) - '@hono/node-ws': 1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9) + '@hono/node-ws': 1.0.4(@hono/node-server@1.12.2(hono@4.11.8))(bufferutil@4.0.9) '@open-draft/deferred-promise': 2.2.0 '@types/ws': 8.5.12 hono: 4.5.11 @@ -23910,7 +23910,7 @@ snapshots: dependencies: hono: 4.11.8 - '@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9)': + '@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.11.8))(bufferutil@4.0.9)': dependencies: '@hono/node-server': 1.12.2(hono@4.5.11) ws: 8.18.3(bufferutil@4.0.9) @@ -30720,7 +30720,7 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - '@trigger.dev/platform@1.0.23': + '@trigger.dev/platform@1.0.24': dependencies: zod: 3.23.8 @@ -39223,7 +39223,7 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0): + react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0): dependencies: '@babel/parser': 7.24.1 '@radix-ui/colors': 1.0.1 @@ -39260,8 +39260,8 @@ snapshots: react: 18.3.1 react-dom: 18.2.0(react@18.3.1) shelljs: 0.8.5 - socket.io: 4.7.3(bufferutil@4.0.9) - socket.io-client: 4.7.3(bufferutil@4.0.9) + socket.io: 4.7.3 + socket.io-client: 4.7.3 sonner: 1.3.1(react-dom@18.2.0(react@18.3.1))(react@18.3.1) source-map-js: 1.0.2 stacktrace-parser: 0.1.10 @@ -40461,7 +40461,7 @@ snapshots: - supports-color - utf-8-validate - socket.io-client@4.7.3(bufferutil@4.0.9): + socket.io-client@4.7.3: dependencies: '@socket.io/component-emitter': 3.1.0 debug: 4.3.7(supports-color@10.0.0) @@ -40490,7 +40490,7 @@ snapshots: transitivePeerDependencies: - supports-color - socket.io@4.7.3(bufferutil@4.0.9): + socket.io@4.7.3: dependencies: accepts: 1.3.8 base64id: 2.0.0