Merge pull request #4745 from Dokploy/feat/plan-limits

Feat/plan limits
This commit is contained in:
Mauricio Siu
2026-07-06 03:37:31 -06:00
committed by GitHub
34 changed files with 470 additions and 140 deletions

View File

@@ -188,6 +188,9 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<FormLabel>Bitbucket Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -196,7 +199,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -245,7 +247,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -333,7 +335,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -201,6 +201,9 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<FormLabel>Gitea Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -208,7 +211,6 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -258,7 +260,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -353,7 +355,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -177,6 +177,9 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<FormLabel>Github Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -189,7 +192,14 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a Github Account" />
<SelectValue placeholder="Select a Github Account">
{
githubProviders?.find(
(githubProvider) =>
githubProvider.githubId === field.value,
)?.gitProvider.name
}
</SelectValue>
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -233,7 +243,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -243,7 +253,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
)?.name ?? "Select repository")}
)?.name ?? field.value.repo)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -320,16 +330,16 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
{status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
? (branches?.find(
(branch) => branch.name === field.value,
)?.name
)?.name ?? field.value)
: "Select branch"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>

View File

@@ -196,6 +196,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<FormLabel>Gitlab Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -205,7 +208,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -254,7 +256,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -351,7 +353,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -531,7 +531,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -507,11 +507,20 @@ export const HandleVolumeBackups = ({
</SelectTrigger>
</FormControl>
<SelectContent>
{mounts?.map((mount) => (
<SelectItem key={mount.Name} value={mount.Name || ""}>
{mount.Name}
{mounts && mounts.length > 0 ? (
mounts.map((mount) => (
<SelectItem
key={mount.Name}
value={mount.Name || ""}
>
{mount.Name}
</SelectItem>
))
) : (
<SelectItem value="none" disabled>
No volumes found
</SelectItem>
))}
)}
</SelectContent>
</Select>
<FormDescription>

View File

@@ -181,7 +181,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -263,7 +263,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -190,6 +190,9 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<FormLabel>Bitbucket Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -198,7 +201,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -247,7 +249,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -335,7 +337,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -188,6 +188,9 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<FormLabel>Gitea Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -195,7 +198,6 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -244,7 +246,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -331,7 +333,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -138,7 +138,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data?.composeId, form]);
}, [form.reset, data]);
const onSubmit = async (data: GithubProvider) => {
await mutateAsync({
@@ -179,6 +179,9 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<FormLabel>Github Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -186,7 +189,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -234,7 +236,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -244,7 +246,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
)?.name ?? "Select repository")}
)?.name ?? field.value.repo)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -321,16 +323,16 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
{status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
? (branches?.find(
(branch) => branch.name === field.value,
)?.name
)?.name ?? field.value)
: "Select branch"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>

View File

@@ -199,6 +199,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<FormLabel>Gitlab Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -208,7 +211,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -256,7 +258,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -353,7 +355,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -409,7 +409,7 @@ export const HandleBackup = ({
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -345,7 +345,7 @@ export const RestoreBackup = ({
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -427,7 +427,7 @@ export const RestoreBackup = ({
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -93,7 +93,8 @@ export function AddOrganization({ organizationId }: Props) {
.catch((error) => {
console.error(error);
toast.error(
`Failed to ${organizationId ? "update" : "create"} organization`,
error?.message ??
`Failed to ${organizationId ? "update" : "create"} organization`,
);
});
};

View File

@@ -33,7 +33,7 @@ export const ShowNodeApplications = ({ serverId }: Props) => {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="w-full">
<Button variant="outline" size="sm">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
</Button>
</DialogTrigger>
@@ -82,7 +82,7 @@ export const ShowNodeApplications = ({ serverId }: Props) => {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="w-full">
<Button variant="outline" size="sm">
<Layers className="h-4 w-4 mr-2" />
Services
</Button>

View File

@@ -110,7 +110,7 @@ export function NodeCard({ node, serverId }: Props) {
</div>
</div>
<div className="flex justify-end w-full space-x-4">
<div className="flex justify-end w-full gap-4">
<ShowNodeConfig nodeId={node.ID} serverId={serverId} />
<ShowNodeApplications serverId={serverId} />
</div>

View File

@@ -24,7 +24,7 @@ export const ShowNodeConfig = ({ nodeId, serverId }: Props) => {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="w-full">
<Button variant="outline" size="sm">
<Settings className="h-4 w-4 mr-2" />
Config
</Button>

View File

@@ -94,9 +94,10 @@ export function TagFilter({
<CommandEmpty>
<div className="flex flex-col items-center gap-2 py-1">
<span className="text-sm text-muted-foreground">
No tags found.
{tags.length === 0
? "No tags created yet."
: "No tags found."}
</span>
<HandleTag />
</div>
</CommandEmpty>
<CommandGroup>
@@ -118,6 +119,9 @@ export function TagFilter({
);
})}
</CommandGroup>
<div className="flex items-center justify-center p-2 border-t">
<HandleTag />
</div>
</CommandList>
</Command>
</PopoverContent>

View File

@@ -111,19 +111,12 @@ export function TagSelector({
<CommandEmpty>
<div className="flex flex-col items-center gap-2 py-1">
<span className="text-sm text-muted-foreground">
No tags found.
{tags.length === 0
? "No tags created yet."
: "No tags found."}
</span>
<HandleTag />
</div>
</CommandEmpty>
{tags.length === 0 && (
<div className="flex flex-col items-center gap-2 py-4">
<span className="text-sm text-muted-foreground">
No tags created yet.
</span>
<HandleTag />
</div>
)}
<CommandGroup>
{tags.map((tag) => {
const isSelected = selectedTags.includes(tag.id);
@@ -153,6 +146,9 @@ export function TagSelector({
);
})}
</CommandGroup>
<div className="flex items-center justify-center p-2 border-t">
<HandleTag />
</div>
</CommandList>
</Command>
</PopoverContent>

View File

@@ -3,6 +3,7 @@ import { Dialog as DialogPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { wasNestedPopupJustClosed } from "@/components/ui/nested-popup-context";
import { XIcon } from "lucide-react";
function Dialog({
@@ -49,6 +50,8 @@ function DialogContent({
className,
children,
showCloseButton = true,
onPointerDownOutside,
onEscapeKeyDown,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
@@ -62,6 +65,20 @@ function DialogContent({
"fixed top-1/2 left-1/2 z-50 flex max-h-[90vh] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-y-auto overscroll-contain rounded-xl bg-popover p-6 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
onPointerDownOutside={(event) => {
if (wasNestedPopupJustClosed()) {
event.preventDefault();
return;
}
onPointerDownOutside?.(event);
}}
onEscapeKeyDown={(event) => {
if (wasNestedPopupJustClosed()) {
event.preventDefault();
return;
}
onEscapeKeyDown?.(event);
}}
{...props}
>
{children}

View File

@@ -2,12 +2,25 @@ import * as React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
import { CheckIcon, ChevronRightIcon } from "lucide-react";
function DropdownMenu({
onOpenChange,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
return (
<DropdownMenuPrimitive.Root
data-slot="dropdown-menu"
onOpenChange={(open) => {
if (!open) {
markNestedPopupClosed();
}
onOpenChange?.(open);
}}
{...props}
/>
);
}
function DropdownMenuPortal({

View File

@@ -0,0 +1,9 @@
let lastNestedPopupCloseAt = 0;
export function markNestedPopupClosed() {
lastNestedPopupCloseAt = performance.now();
}
export function wasNestedPopupJustClosed() {
return performance.now() - lastNestedPopupCloseAt < 100;
}

View File

@@ -4,11 +4,24 @@ import * as React from "react";
import { Popover as PopoverPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
function Popover({
onOpenChange,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
return (
<PopoverPrimitive.Root
data-slot="popover"
onOpenChange={(open) => {
if (!open) {
markNestedPopupClosed();
}
onOpenChange?.(open);
}}
{...props}
/>
);
}
function PopoverTrigger({

View File

@@ -58,7 +58,7 @@ function SelectTrigger({
function SelectContent({
className,
children,
position = "item-aligned",
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {

View File

@@ -1878,7 +1878,7 @@ export async function getServerSideProps(
// Try to find default, otherwise use first accessible
const targetEnv =
accessibleEnvironments.find((env) => env.isDefault) ||
accessibleEnvironments[0];
accessibleEnvironments[0]!;
return {
redirect: {

View File

@@ -1,6 +1,7 @@
import {
createBackup,
findBackupById,
findBackupsByDbId,
findComposeByBackupId,
findComposeById,
findLibsqlByBackupId,
@@ -55,6 +56,7 @@ import {
withPermission,
} from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import { assertDatabaseBackupLimit } from "@/server/api/utils/plan-limits";
import {
apiCreateBackup,
apiFindOneBackup,
@@ -94,6 +96,22 @@ export const backupRouter = createTRPCRouter({
});
}
if (IS_CLOUD) {
const dbType = (
["postgres", "mysql", "mariadb", "mongo", "libsql"] as const
).find((type) => input[`${type}Id`]);
if (dbType) {
const existingBackups = await findBackupsByDbId(
input[`${dbType}Id`]!,
dbType,
);
await assertDatabaseBackupLimit(
ctx.session.activeOrganizationId,
existingBackups.length,
);
}
}
const newBackup = await createBackup(input);
const backup = await findBackupById(newBackup.backupId);

View File

@@ -2,8 +2,10 @@ import {
createEnvironment,
deleteEnvironment,
duplicateEnvironment,
filterEnvironmentServices,
findEnvironmentById,
findEnvironmentsByProjectId,
IS_CLOUD,
updateEnvironmentById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
@@ -20,6 +22,7 @@ import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import { assertEnvironmentLimit } from "@/server/api/utils/plan-limits";
import {
apiCreateEnvironment,
apiDuplicateEnvironment,
@@ -30,43 +33,18 @@ import {
projects,
} from "@/server/db/schema";
const filterEnvironmentServices = (
environment: any,
accessedServices: string[],
) => ({
...environment,
applications: environment.applications.filter((app: any) =>
accessedServices.includes(app.applicationId),
),
compose: environment.compose.filter((comp: any) =>
accessedServices.includes(comp.composeId),
),
libsql: environment.libsql.filter((db: any) =>
accessedServices.includes(db.libsqlId),
),
mariadb: environment.mariadb.filter((db: any) =>
accessedServices.includes(db.mariadbId),
),
mongo: environment.mongo.filter((db: any) =>
accessedServices.includes(db.mongoId),
),
mysql: environment.mysql.filter((db: any) =>
accessedServices.includes(db.mysqlId),
),
postgres: environment.postgres.filter((db: any) =>
accessedServices.includes(db.postgresId),
),
redis: environment.redis.filter((db: any) =>
accessedServices.includes(db.redisId),
),
});
export const environmentRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateEnvironment)
.mutation(async ({ input, ctx }) => {
try {
await checkEnvironmentCreationPermission(ctx, input.projectId);
if (IS_CLOUD) {
await assertEnvironmentLimit(
ctx.session.activeOrganizationId,
input.projectId,
);
}
if (input.name === "production") {
throw new TRPCError({

View File

@@ -5,6 +5,10 @@ import { and, desc, eq, exists } from "drizzle-orm";
import { nanoid } from "nanoid";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import {
assertMemberLimit,
assertOrganizationLimit,
} from "@/server/api/utils/plan-limits";
import {
invitation,
member,
@@ -28,6 +32,11 @@ export const organizationRouter = createTRPCRouter({
message: "Only the organization owner can create an organization",
});
}
if (IS_CLOUD) {
await assertOrganizationLimit(ctx.user.id);
}
const result = await db
.insert(organization)
.values({
@@ -258,6 +267,10 @@ export const organizationRouter = createTRPCRouter({
const orgId = ctx.session.activeOrganizationId;
const email = input.email.toLowerCase();
if (IS_CLOUD) {
await assertMemberLimit(orgId);
}
// Check if user is already a member
const existingUser = await db.query.user.findFirst({
where: eq(user.email, email),

View File

@@ -23,6 +23,7 @@ import { TRPCError } from "@trpc/server";
import { asc, desc, eq } from "drizzle-orm";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { assertScheduledJobLimit } from "@/server/api/utils/plan-limits";
import { removeJob, schedule } from "@/server/utils/backup";
import { createTRPCRouter, protectedProcedure } from "../trpc";
@@ -35,6 +36,13 @@ export const scheduleRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["create"],
});
if (IS_CLOUD) {
await assertScheduledJobLimit(
ctx.session.activeOrganizationId,
input.applicationId ? "application" : "compose",
serviceId,
);
}
} else {
if (input.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
@@ -73,6 +81,14 @@ export const scheduleRouter = createTRPCRouter({
message: "You don't have access to this server.",
});
}
if (IS_CLOUD) {
await assertScheduledJobLimit(
ctx.session.activeOrganizationId,
"server",
input.serverId,
);
}
}
}
const newSchedule = await createSchedule({

View File

@@ -7,6 +7,7 @@ import {
import { TRPCError } from "@trpc/server";
import Stripe from "stripe";
import { z } from "zod";
import { getCurrentPlan as getCurrentPlanForOrganization } from "@/server/utils/billing";
import {
type BillingTier,
getStripeItems,
@@ -31,44 +32,7 @@ import {
export const stripeRouter = createTRPCRouter({
/** Returns the current billing plan for the user's organization. Used to gate features like chat (Startup only). */
getCurrentPlan: protectedProcedure.query(async ({ ctx }) => {
if (!IS_CLOUD) return null;
const owner = await findUserById(ctx.user.ownerId);
if (!owner?.stripeCustomerId) return null;
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});
const subscriptions = await stripe.subscriptions.list({
customer: owner.stripeCustomerId,
status: "active",
expand: ["data.items.data.price"],
});
const activeSub = subscriptions.data[0];
if (!activeSub) return null;
const priceIds = activeSub.items.data.map(
(item) => (item.price as Stripe.Price).id,
);
if (
priceIds.some(
(id) =>
id === STARTUP_BASE_PRICE_MONTHLY_ID ||
id === STARTUP_BASE_PRICE_ANNUAL_ID,
)
) {
return "startup" as const;
}
if (
priceIds.some(
(id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
)
) {
return "hobby" as const;
}
if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
return "legacy" as const;
}
return null;
return getCurrentPlanForOrganization(ctx.session.activeOrganizationId);
}),
getProducts: adminProcedure.query(async ({ ctx }) => {

View File

@@ -27,6 +27,7 @@ import { observable } from "@trpc/server/observable";
import { desc, eq } from "drizzle-orm";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { assertVolumeBackupLimit } from "@/server/api/utils/plan-limits";
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
@@ -69,20 +70,33 @@ export const volumeBackupsRouter = createTRPCRouter({
create: protectedProcedure
.input(createVolumeBackupSchema)
.mutation(async ({ input, ctx }) => {
const serviceId =
input.applicationId ||
input.postgresId ||
input.mysqlId ||
input.mariadbId ||
input.mongoId ||
input.redisId ||
input.libsqlId ||
input.composeId;
const serviceType = (
[
"application",
"postgres",
"mysql",
"mariadb",
"mongo",
"redis",
"libsql",
"compose",
] as const
).find((type) => input[`${type}Id`]);
const serviceId = serviceType ? input[`${serviceType}Id`] : undefined;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
volumeBackup: ["create"],
});
}
if (IS_CLOUD && serviceType && serviceId) {
const existingVolumeBackups = await db.query.volumeBackups.findMany({
where: eq(volumeBackups[`${serviceType}Id`], serviceId),
});
await assertVolumeBackupLimit(
ctx.session.activeOrganizationId,
existingVolumeBackups.length,
);
}
const newVolumeBackup = await createVolumeBackup(input);
if (newVolumeBackup?.enabled) {

View File

@@ -0,0 +1,130 @@
import { db } from "@dokploy/server/db";
import {
environments,
member,
organization,
schedules,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { getCurrentPlan, getCurrentPlanForUser } from "@/server/utils/billing";
export type PlanLimitResource =
| "organization"
| "member"
| "environment"
| "volumeBackup"
| "databaseBackup"
| "scheduledJob";
const UNLIMITED = Number.POSITIVE_INFINITY;
export const PLAN_LIMITS: Record<
"hobby" | "startup" | "legacy",
Record<PlanLimitResource, number>
> = {
hobby: {
organization: 1,
member: 1,
environment: 2,
volumeBackup: 1,
databaseBackup: 1,
scheduledJob: 1,
},
startup: {
organization: 3,
member: UNLIMITED,
environment: UNLIMITED,
volumeBackup: UNLIMITED,
databaseBackup: UNLIMITED,
scheduledJob: UNLIMITED,
},
legacy: {
organization: UNLIMITED,
member: UNLIMITED,
environment: UNLIMITED,
volumeBackup: UNLIMITED,
databaseBackup: UNLIMITED,
scheduledJob: UNLIMITED,
},
};
const resourceLabels: Record<PlanLimitResource, string> = {
organization: "organizations",
member: "users",
environment: "environments per project",
volumeBackup: "volume backups per application",
databaseBackup: "backups per database",
scheduledJob: "scheduled jobs per service",
};
const assertLimitForPlan = (
plan: "hobby" | "startup" | "legacy" | null,
resource: PlanLimitResource,
currentCount: number,
) => {
const limit = PLAN_LIMITS[plan ?? "legacy"][resource];
if (currentCount >= limit) {
throw new TRPCError({
code: "FORBIDDEN",
message: `You've reached your plan's limit of ${limit} ${resourceLabels[resource]}. Upgrade your plan to add more.`,
});
}
};
export const assertOrganizationLimit = async (userId: string) => {
const plan = await getCurrentPlanForUser(userId);
const organizations = await db.query.organization.findMany({
where: eq(organization.ownerId, userId),
});
assertLimitForPlan(plan, "organization", organizations.length);
};
export const assertMemberLimit = async (organizationId: string) => {
const plan = await getCurrentPlan(organizationId);
const members = await db.query.member.findMany({
where: eq(member.organizationId, organizationId),
});
assertLimitForPlan(plan, "member", members.length);
};
export const assertEnvironmentLimit = async (
organizationId: string,
projectId: string,
) => {
const plan = await getCurrentPlan(organizationId);
const envs = await db.query.environments.findMany({
where: eq(environments.projectId, projectId),
});
assertLimitForPlan(plan, "environment", envs.length);
};
export const assertVolumeBackupLimit = async (
organizationId: string,
currentCount: number,
) => {
const plan = await getCurrentPlan(organizationId);
assertLimitForPlan(plan, "volumeBackup", currentCount);
};
export const assertDatabaseBackupLimit = async (
organizationId: string,
currentCount: number,
) => {
const plan = await getCurrentPlan(organizationId);
assertLimitForPlan(plan, "databaseBackup", currentCount);
};
export const assertScheduledJobLimit = async (
organizationId: string,
scheduleType: "application" | "compose" | "server",
serviceId: string,
) => {
const plan = await getCurrentPlan(organizationId);
const column = `${scheduleType}Id` as const;
const rows = await db.query.schedules.findMany({
where: eq(schedules[column], serviceId),
});
assertLimitForPlan(plan, "scheduledJob", rows.length);
};

View File

@@ -0,0 +1,69 @@
import { findUserById, IS_CLOUD } from "@dokploy/server";
import { getOrganizationOwnerId } from "@dokploy/server/services/proprietary/sso";
import Stripe from "stripe";
import {
HOBBY_PRICE_ANNUAL_ID,
HOBBY_PRICE_MONTHLY_ID,
LEGACY_PRICE_IDS,
STARTUP_BASE_PRICE_ANNUAL_ID,
STARTUP_BASE_PRICE_MONTHLY_ID,
} from "@/server/utils/stripe";
export type BillingPlan = "legacy" | "hobby" | "startup";
export const getCurrentPlanForUser = async (
userId: string,
): Promise<BillingPlan | null> => {
if (!IS_CLOUD) return null;
const owner = await findUserById(userId);
if (!owner?.stripeCustomerId) return null;
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});
const subscriptions = await stripe.subscriptions.list({
customer: owner.stripeCustomerId,
status: "active",
expand: ["data.items.data.price"],
});
const activeSub = subscriptions.data[0];
if (!activeSub) return null;
const priceIds = activeSub.items.data.map(
(item) => (item.price as Stripe.Price).id,
);
if (
priceIds.some(
(id) =>
id === STARTUP_BASE_PRICE_MONTHLY_ID ||
id === STARTUP_BASE_PRICE_ANNUAL_ID,
)
) {
return "startup";
}
if (
priceIds.some(
(id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
)
) {
return "hobby";
}
if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
return "legacy";
}
return null;
};
export const getCurrentPlan = async (
organizationId: string,
): Promise<BillingPlan | null> => {
if (!IS_CLOUD) return null;
const ownerId = await getOrganizationOwnerId(organizationId);
if (!ownerId) return null;
return getCurrentPlanForUser(ownerId);
};

View File

@@ -308,6 +308,48 @@ export const duplicateEnvironment = async (
return newEnvironment;
};
interface EnvironmentWithServices {
applications: { applicationId: string }[];
compose: { composeId: string }[];
libsql: { libsqlId: string }[];
mariadb: { mariadbId: string }[];
mongo: { mongoId: string }[];
mysql: { mysqlId: string }[];
postgres: { postgresId: string }[];
redis: { redisId: string }[];
}
export const filterEnvironmentServices = <T extends EnvironmentWithServices>(
environment: T,
accessedServices: string[],
): T => ({
...environment,
applications: environment.applications.filter((app) =>
accessedServices.includes(app.applicationId),
),
compose: environment.compose.filter((comp) =>
accessedServices.includes(comp.composeId),
),
libsql: environment.libsql.filter((db) =>
accessedServices.includes(db.libsqlId),
),
mariadb: environment.mariadb.filter((db) =>
accessedServices.includes(db.mariadbId),
),
mongo: environment.mongo.filter((db) =>
accessedServices.includes(db.mongoId),
),
mysql: environment.mysql.filter((db) =>
accessedServices.includes(db.mysqlId),
),
postgres: environment.postgres.filter((db) =>
accessedServices.includes(db.postgresId),
),
redis: environment.redis.filter((db) =>
accessedServices.includes(db.redisId),
),
});
export const createProductionEnvironment = async (projectId: string) => {
const newEnvironment = await db
.insert(environments)