Usage billing alerts (#2323)
* First draft billing alerts page * Budget alert form working * Don't let free plan users change the billing alert amount * Fix missing key in map in the form * Disable queues/org from admin API endpoint * Don't allow resuming if runsEnabled is false * Refer to "Billing alerts" not "Plans" Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Form missing dependencies fix Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Deal with thrown errors, fix for duplicating email fields * Added a RuntimeEnvironment organizationId index --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -30,8 +30,8 @@ export function UpgradePrompt() {
|
||||
<Icon icon={ExclamationCircleIcon} className="h-5 w-5 text-error" />
|
||||
<Paragraph variant="small" className="text-error">
|
||||
You have exceeded the monthly $
|
||||
{(plan.v3Subscription?.plan?.limits.includedUsage ?? 500) / 100} free credits. No runs
|
||||
will execute in Prod until{" "}
|
||||
{(plan.v3Subscription?.plan?.limits.includedUsage ?? 500) / 100} free credits. Existing
|
||||
runs will be queued and new runs won't be created until{" "}
|
||||
<DateTime date={nextMonth} includeTime={false} timeZone="utc" />, or you upgrade.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BellAlertIcon,
|
||||
ChartBarIcon,
|
||||
Cog8ToothIcon,
|
||||
CreditCardIcon,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
organizationSettingsPath,
|
||||
organizationTeamPath,
|
||||
rootPath,
|
||||
v3BillingAlertsPath,
|
||||
v3BillingPath,
|
||||
v3UsagePath,
|
||||
} from "~/utils/pathBuilder";
|
||||
@@ -67,27 +69,34 @@ export function OrganizationSettingsSideMenu({
|
||||
<SideMenuHeader title="Organization" />
|
||||
</div>
|
||||
{isManagedCloud && (
|
||||
<SideMenuItem
|
||||
name="Usage"
|
||||
icon={ChartBarIcon}
|
||||
activeIconColor="text-indigo-500"
|
||||
to={v3UsagePath(organization)}
|
||||
data-action="usage"
|
||||
/>
|
||||
)}
|
||||
{isManagedCloud && (
|
||||
<SideMenuItem
|
||||
name="Billing"
|
||||
icon={CreditCardIcon}
|
||||
activeIconColor="text-emerald-500"
|
||||
to={v3BillingPath(organization)}
|
||||
data-action="billing"
|
||||
badge={
|
||||
currentPlan?.v3Subscription?.isPaying ? (
|
||||
<Badge variant="extra-small">{currentPlan?.v3Subscription?.plan?.title}</Badge>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<SideMenuItem
|
||||
name="Usage"
|
||||
icon={ChartBarIcon}
|
||||
activeIconColor="text-indigo-500"
|
||||
to={v3UsagePath(organization)}
|
||||
data-action="usage"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Billing"
|
||||
icon={CreditCardIcon}
|
||||
activeIconColor="text-emerald-500"
|
||||
to={v3BillingPath(organization)}
|
||||
data-action="billing"
|
||||
badge={
|
||||
currentPlan?.v3Subscription?.isPaying ? (
|
||||
<Badge variant="extra-small">{currentPlan?.v3Subscription?.plan?.title}</Badge>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Billing alerts"
|
||||
icon={BellAlertIcon}
|
||||
activeIconColor="text-rose-500"
|
||||
to={v3BillingAlertsPath(organization)}
|
||||
data-action="billing-alerts"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Team"
|
||||
|
||||
@@ -7,7 +7,7 @@ const containerBase =
|
||||
"has-[:focus-visible]:outline-none has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-charcoal-650 has-[:focus-visible]:ring-offset-0 has-[:focus]:border-ring has-[:focus]:outline-none has-[:focus]:ring-1 has-[:focus]:ring-ring has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 ring-offset-background transition cursor-text";
|
||||
|
||||
const inputBase =
|
||||
"h-full w-full text-text-bright bg-transparent file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed outline-none ring-0 border-none";
|
||||
"h-full w-full text-text-bright bg-transparent file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed outline-none ring-0 border-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:m-0 [&::-webkit-inner-spin-button]:m-0 [&]:[-moz-appearance:textfield]";
|
||||
|
||||
const variants = {
|
||||
large: {
|
||||
|
||||
@@ -8,6 +8,7 @@ export type Environment = {
|
||||
queued: number;
|
||||
concurrencyLimit: number;
|
||||
burstFactor: number;
|
||||
runsEnabled: boolean;
|
||||
};
|
||||
|
||||
export class EnvironmentQueuePresenter extends BasePresenter {
|
||||
@@ -23,11 +24,25 @@ export class EnvironmentQueuePresenter extends BasePresenter {
|
||||
const running = (engineV1Executing ?? 0) + (engineV2Executing ?? 0);
|
||||
const queued = (engineV1Queued ?? 0) + (engineV2Queued ?? 0);
|
||||
|
||||
const organization = await this._replica.organization.findFirst({
|
||||
where: {
|
||||
id: environment.organizationId,
|
||||
},
|
||||
select: {
|
||||
runsEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
return {
|
||||
running,
|
||||
queued,
|
||||
concurrencyLimit: environment.maximumConcurrencyLimit,
|
||||
burstFactor: environment.concurrencyLimitBurstFactor.toNumber(),
|
||||
runsEnabled: environment.type === "DEVELOPMENT" || organization.runsEnabled,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -285,7 +285,7 @@ export default function Page() {
|
||||
>
|
||||
View runs
|
||||
</LinkButton>
|
||||
<EnvironmentPauseResumeButton env={env} />
|
||||
{environment.runsEnabled ? <EnvironmentPauseResumeButton env={env} /> : null}
|
||||
</div>
|
||||
}
|
||||
valueClassName={env.paused ? "text-warning" : undefined}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { conform, list, requestIntent, useFieldList, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { Form, useActionData, type MetaFunction } from "@remix-run/react";
|
||||
import { json, type ActionFunction, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import {
|
||||
MainHorizontallyCenteredContainer,
|
||||
PageBody,
|
||||
PageContainer,
|
||||
} from "~/components/layout/AppLayout";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { prisma } from "~/db.server";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { getBillingAlerts, setBillingAlert } from "~/services/platform.v3.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { formatCurrency } from "~/utils/numberFormatter";
|
||||
import {
|
||||
OrganizationParamsSchema,
|
||||
organizationPath,
|
||||
v3BillingAlertsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Billing alerts | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
await requireUserId(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const { isManagedCloud } = featuresForRequest(request);
|
||||
if (!isManagedCloud) {
|
||||
return redirect(organizationPath({ slug: organizationSlug }));
|
||||
}
|
||||
|
||||
const organization = await prisma.organization.findUnique({
|
||||
where: { slug: organizationSlug },
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw new Response(null, { status: 404, statusText: "Organization not found" });
|
||||
}
|
||||
|
||||
const [error, alerts] = await tryCatch(getBillingAlerts(organization.id));
|
||||
if (error) {
|
||||
throw new Response(null, { status: 404, statusText: `Billing alerts error: ${error}` });
|
||||
}
|
||||
|
||||
if (!alerts) {
|
||||
throw new Response(null, { status: 404, statusText: "Billing alerts not found" });
|
||||
}
|
||||
|
||||
return typedjson({
|
||||
alerts: {
|
||||
...alerts,
|
||||
amount: alerts.amount / 100,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
amount: z
|
||||
.number({ invalid_type_error: "Not a valid amount" })
|
||||
.min(0, "Amount must be greater than 0"),
|
||||
emails: z.preprocess((i) => {
|
||||
if (typeof i === "string") return [i];
|
||||
|
||||
if (Array.isArray(i)) {
|
||||
const emails = i.filter((v) => typeof v === "string" && v !== "");
|
||||
if (emails.length === 0) {
|
||||
return [""];
|
||||
}
|
||||
return emails;
|
||||
}
|
||||
|
||||
return [""];
|
||||
}, z.string().email().array().nonempty("At least one email is required")),
|
||||
alertLevels: z.preprocess((i) => {
|
||||
if (typeof i === "string") return [i];
|
||||
return i;
|
||||
}, z.coerce.number().array().nonempty("At least one alert level is required")),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
try {
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: { slug: organizationSlug, members: { some: { userId } } },
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
return redirectWithErrorMessage(
|
||||
v3BillingAlertsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"You are not authorized to update billing alerts"
|
||||
);
|
||||
}
|
||||
|
||||
const [error, updatedAlert] = await tryCatch(
|
||||
setBillingAlert(organization.id, {
|
||||
...submission.value,
|
||||
amount: submission.value.amount * 100,
|
||||
})
|
||||
);
|
||||
if (error) {
|
||||
return redirectWithErrorMessage(
|
||||
v3BillingAlertsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Failed to update billing alert"
|
||||
);
|
||||
}
|
||||
|
||||
if (!updatedAlert) {
|
||||
return redirectWithErrorMessage(
|
||||
v3BillingAlertsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Failed to update billing alert"
|
||||
);
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3BillingAlertsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"Billing alert updated"
|
||||
);
|
||||
} catch (error: any) {
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { alerts } = useTypedLoaderData<typeof loader>();
|
||||
const plan = useCurrentPlan();
|
||||
const [dollarAmount, setDollarAmount] = useState(alerts.amount.toFixed(2));
|
||||
|
||||
const lastSubmission = useActionData();
|
||||
|
||||
const [form, { emails, amount, alertLevels }] = useForm({
|
||||
id: "invite-members",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema });
|
||||
},
|
||||
defaultValue: {
|
||||
emails: [""],
|
||||
},
|
||||
});
|
||||
|
||||
const fieldValues = useRef<string[]>(alerts.emails);
|
||||
const emailFields = useFieldList(form.ref, { ...emails, defaultValue: alerts.emails });
|
||||
|
||||
const checkboxLevels = [0.75, 0.9, 1.0];
|
||||
|
||||
useEffect(() => {
|
||||
if (alerts.emails.length > 0) {
|
||||
requestIntent(form.ref.current ?? undefined, list.append(emails.name));
|
||||
}
|
||||
}, [emails.name, form.ref]);
|
||||
const isFree = !plan?.v3Subscription?.isPaying;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Billing alerts" />
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip />
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={true}>
|
||||
<MainHorizontallyCenteredContainer>
|
||||
<div>
|
||||
<Header2 spacing>Billing alerts</Header2>
|
||||
<Paragraph spacing variant="small">
|
||||
Receive an email when your compute spend crosses different thresholds.
|
||||
</Paragraph>
|
||||
<Form method="post" {...form.props}>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={amount.id}>Amount</Label>
|
||||
{isFree ? (
|
||||
<>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
${dollarAmount}
|
||||
</Paragraph>
|
||||
<input type="hidden" name={amount.name} value={dollarAmount} />
|
||||
</>
|
||||
) : (
|
||||
<Input
|
||||
{...conform.input(amount, { type: "number" })}
|
||||
value={dollarAmount}
|
||||
onChange={(e) => {
|
||||
const numberValue = Number(e.target.value);
|
||||
if (numberValue < 0) {
|
||||
setDollarAmount("");
|
||||
return;
|
||||
}
|
||||
setDollarAmount(e.target.value);
|
||||
}}
|
||||
step={0.01}
|
||||
min={0}
|
||||
placeholder="Enter an amount"
|
||||
icon={
|
||||
<span className="-mt-0.5 block pl-0.5 text-sm text-text-dimmed">$</span>
|
||||
}
|
||||
className="pl-px"
|
||||
fullWidth
|
||||
readOnly={isFree}
|
||||
/>
|
||||
)}
|
||||
<FormError id={amount.errorId}>{amount.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={alertLevels.id}>Alert me when I reach</Label>
|
||||
{checkboxLevels.map((level) => (
|
||||
<CheckboxWithLabel
|
||||
name={alertLevels.name}
|
||||
id={`level_${level}`}
|
||||
key={level}
|
||||
value={level.toString()}
|
||||
variant="simple/small"
|
||||
label={
|
||||
<span>
|
||||
{level * 100}%{" "}
|
||||
<span className="text-text-dimmed">
|
||||
({formatCurrency(Number(dollarAmount) * level, false)})
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
defaultChecked={alerts.alertLevels.includes(level)}
|
||||
className="pr-0"
|
||||
readOnly={level === 1.0}
|
||||
/>
|
||||
))}
|
||||
<FormError id={alertLevels.errorId}>{alertLevels.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={emails.id}>Email addresses</Label>
|
||||
{emailFields.map((email, index) => (
|
||||
<Fragment key={email.key}>
|
||||
<Input
|
||||
{...conform.input(email, { type: "email" })}
|
||||
placeholder={index === 0 ? "Enter an email address" : "Add another email"}
|
||||
autoFocus={index === 0}
|
||||
onChange={(e) => {
|
||||
fieldValues.current[index] = e.target.value;
|
||||
if (
|
||||
emailFields.length === fieldValues.current.length &&
|
||||
fieldValues.current.every((v) => v !== "")
|
||||
) {
|
||||
requestIntent(form.ref.current ?? undefined, list.append(emails.name));
|
||||
}
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
<FormError id={email.errorId}>{email.error}</FormError>
|
||||
</Fragment>
|
||||
))}
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button type="submit" variant={"primary/small"}>
|
||||
Update
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</MainHorizontallyCenteredContainer>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CalendarDaysIcon, StarIcon } from "@heroicons/react/20/solid";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { type PlanDefinition } from "@trigger.dev/platform/v3";
|
||||
import { type PlanDefinition } from "@trigger.dev/platform";
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import {
|
||||
type RuntimeEnvironment,
|
||||
type Organization,
|
||||
type Project,
|
||||
type RuntimeEnvironmentType,
|
||||
} from "@trigger.dev/database";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { createEnvironment } from "~/models/organization.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
import { updateEnvConcurrencyLimits } from "~/v3/runQueue.server";
|
||||
import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
organizationId: z.string(),
|
||||
});
|
||||
|
||||
const BodySchema = z.object({
|
||||
enable: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* It will enabled/disable runs
|
||||
*/
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: authenticationResult.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!user.admin) {
|
||||
return json({ error: "You must be an admin to perform this action" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { organizationId } = ParamsSchema.parse(params);
|
||||
const body = BodySchema.safeParse(await request.json());
|
||||
if (!body.success) {
|
||||
return json({ error: "Invalid request body", details: body.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const organization = await prisma.organization.update({
|
||||
where: {
|
||||
id: organizationId,
|
||||
},
|
||||
data: {
|
||||
runsEnabled: body.data.enable,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
return json({ error: "Organization not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const environments = await prisma.runtimeEnvironment.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
type: {
|
||||
not: "DEVELOPMENT",
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
project: true,
|
||||
},
|
||||
});
|
||||
|
||||
const pauseEnvironmentService = new PauseEnvironmentService();
|
||||
|
||||
// Set the organization.runsEnabled flag to false
|
||||
for (const environment of environments) {
|
||||
if (body.data.enable) {
|
||||
await pauseEnvironmentService.call({ ...environment, organization }, "resumed");
|
||||
} else {
|
||||
await pauseEnvironmentService.call({ ...environment, organization }, "paused");
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
message: `${environments.length} environments updated to ${
|
||||
body.data.enable ? "enabled" : "disabled"
|
||||
}`,
|
||||
});
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Plans,
|
||||
SetPlanBody,
|
||||
SubscriptionResult,
|
||||
} from "@trigger.dev/platform/v3";
|
||||
} from "@trigger.dev/platform";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { DefinitionTip } from "~/components/DefinitionTooltip";
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
defaultMachine as defaultMachineFromPlatform,
|
||||
machines as machinesFromPlatform,
|
||||
type MachineCode,
|
||||
} from "@trigger.dev/platform/v3";
|
||||
type UpdateBillingAlertsRequest,
|
||||
type BillingAlertsResult,
|
||||
} from "@trigger.dev/platform";
|
||||
import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
|
||||
import { MemoryStore } from "@unkey/cache/stores";
|
||||
import { redirect } from "remix-typedjson";
|
||||
@@ -467,6 +469,31 @@ export async function projectCreated(organization: Organization, project: Projec
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBillingAlerts(
|
||||
organizationId: string
|
||||
): Promise<BillingAlertsResult | undefined> {
|
||||
if (!client) return undefined;
|
||||
const result = await client.getBillingAlerts(organizationId);
|
||||
if (!result.success) {
|
||||
logger.error("Error getting billing alert", { error: result.error, organizationId });
|
||||
throw new Error("Error getting billing alert");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function setBillingAlert(
|
||||
organizationId: string,
|
||||
alert: UpdateBillingAlertsRequest
|
||||
): Promise<BillingAlertsResult | undefined> {
|
||||
if (!client) return undefined;
|
||||
const result = await client.updateBillingAlerts(organizationId, alert);
|
||||
if (!result.success) {
|
||||
logger.error("Error setting billing alert", { error: result.error, organizationId });
|
||||
throw new Error("Error setting billing alert");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isCloud(): boolean {
|
||||
const acceptableHosts = [
|
||||
"https://cloud.trigger.dev",
|
||||
|
||||
@@ -459,6 +459,10 @@ export function v3BillingPath(organization: OrgForPath, message?: string) {
|
||||
}`;
|
||||
}
|
||||
|
||||
export function v3BillingAlertsPath(organization: OrgForPath) {
|
||||
return `${organizationPath(organization)}/settings/billing-alerts`;
|
||||
}
|
||||
|
||||
export function v3StripePortalPath(organization: OrgForPath) {
|
||||
return `/resources/${organization.slug}/subscription/portal`;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,25 @@ export class PauseEnvironmentService extends WithRunEngine {
|
||||
action: PauseStatus
|
||||
): Promise<PauseEnvironmentResult> {
|
||||
try {
|
||||
const org = await this._prisma.organization.findFirst({
|
||||
where: {
|
||||
id: environment.organizationId,
|
||||
},
|
||||
select: {
|
||||
runsEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
if (!org.runsEnabled && action === "resumed") {
|
||||
throw new Error(
|
||||
"Runs are disabled for this organization. Your free plan has probably been exceeded. If not please contact support."
|
||||
);
|
||||
}
|
||||
|
||||
await this._prisma.runtimeEnvironment.update({
|
||||
where: {
|
||||
id: environment.id,
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
"@trigger.dev/core": "workspace:*",
|
||||
"@trigger.dev/database": "workspace:*",
|
||||
"@trigger.dev/otlp-importer": "workspace:*",
|
||||
"@trigger.dev/platform": "1.0.15",
|
||||
"@trigger.dev/platform": "1.0.17",
|
||||
"@trigger.dev/redis-worker": "workspace:*",
|
||||
"@trigger.dev/sdk": "workspace:*",
|
||||
"@types/pg": "8.6.6",
|
||||
@@ -278,4 +278,4 @@
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ pnpm run docker
|
||||
### How to add a new index on a large table
|
||||
|
||||
1. Modify the Prisma.schema with a single index change (no other changes, just one index at a time)
|
||||
2. Create a Prisma migration using `cd internal-packages/database && pnpm run db:migrate:dev --create-only`
|
||||
2. Create a Prisma migration using `cd internal-packages/database && pnpm run db:migrate:dev:create`
|
||||
3. Modify the SQL file: add IF NOT EXISTS to it and CONCURRENTLY:
|
||||
|
||||
```sql
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "RuntimeEnvironment_organizationId_idx" ON "RuntimeEnvironment" ("organizationId");
|
||||
@@ -300,6 +300,7 @@ model RuntimeEnvironment {
|
||||
@@unique([projectId, shortcode])
|
||||
@@index([parentEnvironmentId])
|
||||
@@index([projectId])
|
||||
@@index([organizationId])
|
||||
}
|
||||
|
||||
enum RuntimeEnvironmentType {
|
||||
|
||||
Generated
+4
-4
@@ -441,8 +441,8 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../internal-packages/otlp-importer
|
||||
'@trigger.dev/platform':
|
||||
specifier: 1.0.15
|
||||
version: 1.0.15
|
||||
specifier: 1.0.17
|
||||
version: 1.0.17
|
||||
'@trigger.dev/redis-worker':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/redis-worker
|
||||
@@ -19571,8 +19571,8 @@ packages:
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@trigger.dev/platform@1.0.15:
|
||||
resolution: {integrity: sha512-rorRJJl7ecyiO8iQZcHGlXR00bTzm7e1xZt0ddCYJFhaQjxq2bo2oen5DVxUbLZsE2cp60ipQWFrmAipFwK79Q==}
|
||||
/@trigger.dev/platform@1.0.17:
|
||||
resolution: {integrity: sha512-cR05nn8HnP03h/bmRN6O/EKgvQncbs3Y/7fp1QboEDWn6rJTRrWJpZVrA3ZQ32SIW1qvHuZLcB1OVaEsJk2wjA==}
|
||||
dependencies:
|
||||
zod: 3.23.8
|
||||
dev: false
|
||||
|
||||
Reference in New Issue
Block a user