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..2c8af504a00 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, useLocation, useNavigation, 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,71 @@ 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().min(0, "Amount must be 0 or more"), + }), + z.object({ + action: z.literal("quota-increase"), + amount: z.coerce.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 redirectWithSuccessMessage( + `${redirectPath}?purchaseSuccess=true`, + request, + submission.value.action === "purchase" + ? "Preview branches updated successfully" + : "Requested extra preview branches, we'll get back to you soon." + ); + } + const submission = parse(formData, { schema }); if (!submission.value) { @@ -165,6 +232,11 @@ export default function Page() { currentPage, totalPages, hasBranches, + canPurchaseBranches, + extraBranches, + branchPricing, + maxBranchQuota, + planBranchLimit, } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); @@ -218,7 +290,15 @@ export default function Page() { {limits.isAtLimit ? ( - + ) : ( - New branch + New branch… } parentEnvironment={branchableEnvironment} @@ -324,7 +404,15 @@ export default function Page() { isSticky hiddenButtons={ isSelected ? null : ( - + + Switch to branch + ) } popoverContent={ @@ -333,8 +421,8 @@ export default function Page() { {isSelected ? null : ( )} @@ -406,21 +494,29 @@ export default function Page() {
)} - {canUpgrade ? ( - - Upgrade - - ) : ( - Request more} - defaultValue="help" + {canPurchaseBranches && branchPricing ? ( + - )} + ) : canUpgrade ? ( +
+ + Upgrade plan for more Preview Branches + + + Upgrade + +
+ ) : null}
@@ -434,42 +530,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 +626,269 @@ 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 lastSubmission = useActionData(); + 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); + const navigation = useNavigation(); + const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST"; + + const [searchParams, setSearchParams] = useSearchParams(); + const purchaseSuccess = searchParams.get("purchaseSuccess"); + const [open, setOpen] = useState(false); + useEffect(() => { + if (purchaseSuccess) { + setOpen(false); + setSearchParams((s) => { + s.delete("purchaseSuccess"); + return s; + }); + } + }, [purchaseSuccess, setSearchParams]); + + 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..ad3b33337f9 --- /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"; +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); + } + } + } +}