@@ -824,7 +945,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingSlack ||
isLoadingTelegram ||
isLoadingDiscord ||
- isLoadingEmail
+ isLoadingEmail ||
+ isLoadingGotify
}
variant="secondary"
onClick={async () => {
@@ -853,6 +975,13 @@ export const HandleNotifications = ({ notificationId }: Props) => {
toAddresses: form.getValues("toAddresses"),
fromAddress: form.getValues("fromAddress"),
});
+ } else if (type === "gotify") {
+ await testGotifyConnection({
+ serverUrl: form.getValues("serverUrl"),
+ appToken: form.getValues("appToken"),
+ priority: form.getValues("priority"),
+ decoration: form.getValues("decoration"),
+ });
}
toast.success("Connection Success");
} catch (err) {
diff --git a/apps/dokploy/components/dashboard/settings/notifications/update-notification.tsx b/apps/dokploy/components/dashboard/settings/notifications/update-notification.tsx
new file mode 100644
index 00000000..3d8b61e6
--- /dev/null
+++ b/apps/dokploy/components/dashboard/settings/notifications/update-notification.tsx
@@ -0,0 +1,864 @@
+import {
+ DiscordIcon,
+ SlackIcon,
+ TelegramIcon,
+} from "@/components/icons/notification-icons";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Switch } from "@/components/ui/switch";
+import { api } from "@/utils/api";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Mail, Pen } from "lucide-react";
+import { useEffect, useState } from "react";
+import { useFieldArray, useForm } from "react-hook-form";
+import { toast } from "sonner";
+import {
+ type NotificationSchema,
+ notificationSchema,
+} from "./add-notification";
+
+interface Props {
+ notificationId: string;
+}
+
+export const UpdateNotification = ({ notificationId }: Props) => {
+ const utils = api.useUtils();
+ const [isOpen, setIsOpen] = useState(false);
+ const { data, refetch } = api.notification.one.useQuery(
+ {
+ notificationId,
+ },
+ {
+ enabled: !!notificationId,
+ },
+ );
+ const { mutateAsync: testSlackConnection, isLoading: isLoadingSlack } =
+ api.notification.testSlackConnection.useMutation();
+
+ const { mutateAsync: testTelegramConnection, isLoading: isLoadingTelegram } =
+ api.notification.testTelegramConnection.useMutation();
+ const { mutateAsync: testDiscordConnection, isLoading: isLoadingDiscord } =
+ api.notification.testDiscordConnection.useMutation();
+ const { mutateAsync: testEmailConnection, isLoading: isLoadingEmail } =
+ api.notification.testEmailConnection.useMutation();
+ const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } =
+ api.notification.testGotifyConnection.useMutation();
+ const slackMutation = api.notification.updateSlack.useMutation();
+ const telegramMutation = api.notification.updateTelegram.useMutation();
+ const discordMutation = api.notification.updateDiscord.useMutation();
+ const emailMutation = api.notification.updateEmail.useMutation();
+ const gotifyMutation = api.notification.updateGotify.useMutation();
+ const { data: isCloud } = api.settings.isCloud.useQuery();
+ const form = useForm
({
+ defaultValues: {
+ type: "slack",
+ webhookUrl: "",
+ channel: "",
+ },
+ resolver: zodResolver(notificationSchema),
+ });
+ const type = form.watch("type");
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "toAddresses" as never,
+ });
+
+ useEffect(() => {
+ if (data) {
+ if (data.notificationType === "slack") {
+ form.reset({
+ appBuildError: data.appBuildError,
+ appDeploy: data.appDeploy,
+ dokployRestart: data.dokployRestart,
+ databaseBackup: data.databaseBackup,
+ dockerCleanup: data.dockerCleanup,
+ webhookUrl: data.slack?.webhookUrl,
+ channel: data.slack?.channel || "",
+ name: data.name,
+ type: data.notificationType,
+ });
+ } else if (data.notificationType === "telegram") {
+ form.reset({
+ appBuildError: data.appBuildError,
+ appDeploy: data.appDeploy,
+ dokployRestart: data.dokployRestart,
+ databaseBackup: data.databaseBackup,
+ botToken: data.telegram?.botToken,
+ chatId: data.telegram?.chatId,
+ type: data.notificationType,
+ name: data.name,
+ dockerCleanup: data.dockerCleanup,
+ });
+ } else if (data.notificationType === "discord") {
+ form.reset({
+ appBuildError: data.appBuildError,
+ appDeploy: data.appDeploy,
+ dokployRestart: data.dokployRestart,
+ databaseBackup: data.databaseBackup,
+ type: data.notificationType,
+ webhookUrl: data.discord?.webhookUrl,
+ decoration: data.discord?.decoration || undefined,
+ name: data.name,
+ dockerCleanup: data.dockerCleanup,
+ });
+ } else if (data.notificationType === "email") {
+ form.reset({
+ appBuildError: data.appBuildError,
+ appDeploy: data.appDeploy,
+ dokployRestart: data.dokployRestart,
+ databaseBackup: data.databaseBackup,
+ type: data.notificationType,
+ smtpServer: data.email?.smtpServer,
+ smtpPort: data.email?.smtpPort,
+ username: data.email?.username,
+ password: data.email?.password,
+ toAddresses: data.email?.toAddresses,
+ fromAddress: data.email?.fromAddress,
+ name: data.name,
+ dockerCleanup: data.dockerCleanup,
+ });
+ } else if (data.notificationType === "gotify") {
+ form.reset({
+ appBuildError: data.appBuildError,
+ appDeploy: data.appDeploy,
+ dokployRestart: data.dokployRestart,
+ databaseBackup: data.databaseBackup,
+ type: data.notificationType,
+ serverUrl: data.gotify?.serverUrl,
+ appToken: data.gotify?.appToken,
+ priority: data.gotify?.priority || 5,
+ decoration: data.gotify?.decoration || undefined,
+ name: data.name,
+ dockerCleanup: data.dockerCleanup,
+ });
+ }
+ }
+ }, [form, form.reset, data]);
+
+ const onSubmit = async (formData: NotificationSchema) => {
+ const {
+ appBuildError,
+ appDeploy,
+ dokployRestart,
+ databaseBackup,
+ dockerCleanup,
+ } = formData;
+ let promise: Promise | null = null;
+ if (formData?.type === "slack" && data?.slackId) {
+ promise = slackMutation.mutateAsync({
+ appBuildError: appBuildError,
+ appDeploy: appDeploy,
+ dokployRestart: dokployRestart,
+ databaseBackup: databaseBackup,
+ webhookUrl: formData.webhookUrl,
+ channel: formData.channel,
+ name: formData.name,
+ notificationId: notificationId,
+ slackId: data?.slackId,
+ dockerCleanup: dockerCleanup,
+ });
+ } else if (formData.type === "telegram" && data?.telegramId) {
+ promise = telegramMutation.mutateAsync({
+ appBuildError: appBuildError,
+ appDeploy: appDeploy,
+ dokployRestart: dokployRestart,
+ databaseBackup: databaseBackup,
+ botToken: formData.botToken,
+ chatId: formData.chatId,
+ name: formData.name,
+ notificationId: notificationId,
+ telegramId: data?.telegramId,
+ dockerCleanup: dockerCleanup,
+ });
+ } else if (formData.type === "discord" && data?.discordId) {
+ promise = discordMutation.mutateAsync({
+ appBuildError: appBuildError,
+ appDeploy: appDeploy,
+ dokployRestart: dokployRestart,
+ databaseBackup: databaseBackup,
+ webhookUrl: formData.webhookUrl,
+ decoration: formData.decoration,
+ name: formData.name,
+ notificationId: notificationId,
+ discordId: data?.discordId,
+ dockerCleanup: dockerCleanup,
+ });
+ } else if (formData.type === "email" && data?.emailId) {
+ promise = emailMutation.mutateAsync({
+ appBuildError: appBuildError,
+ appDeploy: appDeploy,
+ dokployRestart: dokployRestart,
+ databaseBackup: databaseBackup,
+ smtpServer: formData.smtpServer,
+ smtpPort: formData.smtpPort,
+ username: formData.username,
+ password: formData.password,
+ fromAddress: formData.fromAddress,
+ toAddresses: formData.toAddresses,
+ name: formData.name,
+ notificationId: notificationId,
+ emailId: data?.emailId,
+ dockerCleanup: dockerCleanup,
+ });
+ } else if (formData.type === "gotify" && data?.gotifyId) {
+ promise = gotifyMutation.mutateAsync({
+ appBuildError: appBuildError,
+ appDeploy: appDeploy,
+ dokployRestart: dokployRestart,
+ databaseBackup: databaseBackup,
+ serverUrl: formData.serverUrl,
+ appToken: formData.appToken,
+ priority: formData.priority,
+ decoration: formData.decoration,
+ name: formData.name,
+ notificationId: notificationId,
+ gotifyId: data?.gotifyId,
+ dockerCleanup: dockerCleanup,
+ });
+ }
+
+ if (promise) {
+ await promise
+ .then(async () => {
+ toast.success("Notification Updated");
+ await utils.notification.all.invalidate();
+ refetch();
+ setIsOpen(false);
+ })
+ .catch(() => {
+ toast.error("Error updating a notification");
+ });
+ }
+ };
+ return (
+
+ );
+};
diff --git a/apps/dokploy/drizzle/meta/0054_snapshot.json b/apps/dokploy/drizzle/meta/0054_snapshot.json
index 88447e32..cee6723d 100644
--- a/apps/dokploy/drizzle/meta/0054_snapshot.json
+++ b/apps/dokploy/drizzle/meta/0054_snapshot.json
@@ -4250,4 +4250,4 @@
"schemas": {},
"tables": {}
}
-}
\ No newline at end of file
+}
diff --git a/apps/dokploy/drizzle/meta/_journal.json b/apps/dokploy/drizzle/meta/_journal.json
index 572e67c6..418ac97a 100644
--- a/apps/dokploy/drizzle/meta/_journal.json
+++ b/apps/dokploy/drizzle/meta/_journal.json
@@ -395,4 +395,4 @@
"breakpoints": true
}
]
-}
\ No newline at end of file
+}
diff --git a/apps/dokploy/server/api/routers/notification.ts b/apps/dokploy/server/api/routers/notification.ts
index f8869503..77f0287a 100644
--- a/apps/dokploy/server/api/routers/notification.ts
+++ b/apps/dokploy/server/api/routers/notification.ts
@@ -2,20 +2,24 @@ import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
+ publicProcedure,
} from "@/server/api/trpc";
import { db } from "@/server/db";
import {
apiCreateDiscord,
apiCreateEmail,
+ apiCreateGotify,
apiCreateSlack,
apiCreateTelegram,
apiFindOneNotification,
apiTestDiscordConnection,
apiTestEmailConnection,
+ apiTestGotifyConnection,
apiTestSlackConnection,
apiTestTelegramConnection,
apiUpdateDiscord,
apiUpdateEmail,
+ apiUpdateGotify,
apiUpdateSlack,
apiUpdateTelegram,
notifications,
@@ -24,16 +28,19 @@ import {
IS_CLOUD,
createDiscordNotification,
createEmailNotification,
+ createGotifyNotification,
createSlackNotification,
createTelegramNotification,
findNotificationById,
removeNotificationById,
sendDiscordNotification,
sendEmailNotification,
+ sendGotifyNotification,
sendSlackNotification,
sendTelegramNotification,
updateDiscordNotification,
updateEmailNotification,
+ updateGotifyNotification,
updateSlackNotification,
updateTelegramNotification,
} from "@dokploy/server";
@@ -300,10 +307,61 @@ export const notificationRouter = createTRPCRouter({
telegram: true,
discord: true,
email: true,
+ gotify: true,
},
orderBy: desc(notifications.createdAt),
...(IS_CLOUD && { where: eq(notifications.adminId, ctx.user.adminId) }),
// TODO: Remove this line when the cloud version is ready
});
}),
+ createGotify: adminProcedure
+ .input(apiCreateGotify)
+ .mutation(async ({ input, ctx }) => {
+ try {
+ return await createGotifyNotification(input, ctx.user.adminId);
+ } catch (error) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Error creating the notification",
+ cause: error,
+ });
+ }
+ }),
+ updateGotify: adminProcedure
+ .input(apiUpdateGotify)
+ .mutation(async ({ input, ctx }) => {
+ try {
+ const notification = await findNotificationById(input.notificationId);
+ if (IS_CLOUD && notification.adminId !== ctx.user.adminId) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to update this notification",
+ });
+ }
+ return await updateGotifyNotification({
+ ...input,
+ adminId: ctx.user.adminId,
+ });
+ } catch (error) {
+ throw error;
+ }
+ }),
+ testGotifyConnection: adminProcedure
+ .input(apiTestGotifyConnection)
+ .mutation(async ({ input }) => {
+ try {
+ await sendGotifyNotification(
+ input,
+ "Test Notification",
+ "Hi, From Dokploy 👋"
+ );
+ return true;
+ } catch (error) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Error testing the notification",
+ cause: error,
+ });
+ }
+ }),
});
diff --git a/packages/server/src/db/schema/notification.ts b/packages/server/src/db/schema/notification.ts
index 5501621d..bbbe18d2 100644
--- a/packages/server/src/db/schema/notification.ts
+++ b/packages/server/src/db/schema/notification.ts
@@ -10,6 +10,7 @@ export const notificationType = pgEnum("notificationType", [
"telegram",
"discord",
"email",
+ "gotify",
]);
export const notifications = pgTable("notification", {
@@ -39,6 +40,9 @@ export const notifications = pgTable("notification", {
emailId: text("emailId").references(() => email.emailId, {
onDelete: "cascade",
}),
+ gotifyId: text("gotifyId").references(() => gotify.gotifyId, {
+ onDelete: "cascade",
+ }),
adminId: text("adminId").references(() => admins.adminId, {
onDelete: "cascade",
}),
@@ -84,6 +88,17 @@ export const email = pgTable("email", {
toAddresses: text("toAddress").array().notNull(),
});
+export const gotify = pgTable("gotify", {
+ gotifyId: text("gotifyId")
+ .notNull()
+ .primaryKey()
+ .$defaultFn(() => nanoid()),
+ serverUrl: text("serverUrl").notNull(),
+ appToken: text("appToken").notNull(),
+ priority: integer("priority").notNull().default(5),
+ decoration: boolean("decoration"),
+});
+
export const notificationsRelations = relations(notifications, ({ one }) => ({
slack: one(slack, {
fields: [notifications.slackId],
@@ -101,6 +116,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
fields: [notifications.emailId],
references: [email.emailId],
}),
+ gotify: one(gotify, {
+ fields: [notifications.gotifyId],
+ references: [gotify.gotifyId],
+ }),
admin: one(admins, {
fields: [notifications.adminId],
references: [admins.adminId],
@@ -224,6 +243,37 @@ export const apiTestEmailConnection = apiCreateEmail.pick({
fromAddress: true,
});
+export const apiCreateGotify = notificationsSchema
+ .pick({
+ appBuildError: true,
+ databaseBackup: true,
+ dokployRestart: true,
+ name: true,
+ appDeploy: true,
+ dockerCleanup: true,
+ })
+ .extend({
+ serverUrl: z.string().min(1),
+ appToken: z.string().min(1),
+ priority: z.number().min(1),
+ decoration: z.boolean(),
+ })
+ .required();
+
+export const apiUpdateGotify = apiCreateGotify.partial().extend({
+ notificationId: z.string().min(1),
+ gotifyId: z.string().min(1),
+ adminId: z.string().optional(),
+});
+
+export const apiTestGotifyConnection = apiCreateGotify.pick({
+ serverUrl: true,
+ appToken: true,
+ priority: true,
+}).extend({
+ decoration: z.boolean().optional(),
+});
+
export const apiFindOneNotification = notificationsSchema
.pick({
notificationId: true,
@@ -242,5 +292,8 @@ export const apiSendTest = notificationsSchema
username: z.string(),
password: z.string(),
toAddresses: z.array(z.string()),
+ serverUrl: z.string(),
+ appToken: z.string(),
+ priority: z.number(),
})
.partial();
diff --git a/packages/server/src/services/notification.ts b/packages/server/src/services/notification.ts
index e75154df..2b62b457 100644
--- a/packages/server/src/services/notification.ts
+++ b/packages/server/src/services/notification.ts
@@ -2,14 +2,17 @@ import { db } from "@dokploy/server/db";
import {
type apiCreateDiscord,
type apiCreateEmail,
+ type apiCreateGotify,
type apiCreateSlack,
type apiCreateTelegram,
type apiUpdateDiscord,
type apiUpdateEmail,
+ type apiUpdateGotify,
type apiUpdateSlack,
type apiUpdateTelegram,
discord,
email,
+ gotify,
notifications,
slack,
telegram,
@@ -379,6 +382,96 @@ export const updateEmailNotification = async (
});
};
+export const createGotifyNotification = async (
+ input: typeof apiCreateGotify._type,
+ adminId: string,
+) => {
+ await db.transaction(async (tx) => {
+ const newGotify = await tx
+ .insert(gotify)
+ .values({
+ serverUrl: input.serverUrl,
+ appToken: input.appToken,
+ priority: input.priority,
+ decoration: input.decoration,
+ })
+ .returning()
+ .then((value) => value[0]);
+
+ if (!newGotify) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Error input: Inserting gotify",
+ });
+ }
+
+ const newDestination = await tx
+ .insert(notifications)
+ .values({
+ gotifyId: newGotify.gotifyId,
+ name: input.name,
+ appDeploy: input.appDeploy,
+ appBuildError: input.appBuildError,
+ databaseBackup: input.databaseBackup,
+ dokployRestart: input.dokployRestart,
+ dockerCleanup: input.dockerCleanup,
+ notificationType: "gotify",
+ adminId: adminId,
+ })
+ .returning()
+ .then((value) => value[0]);
+
+ if (!newDestination) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Error input: Inserting notification",
+ });
+ }
+
+ return newDestination;
+ });
+};
+
+export const updateGotifyNotification = async (
+ input: typeof apiUpdateGotify._type,
+) => {
+ await db.transaction(async (tx) => {
+ const newDestination = await tx
+ .update(notifications)
+ .set({
+ name: input.name,
+ appDeploy: input.appDeploy,
+ appBuildError: input.appBuildError,
+ databaseBackup: input.databaseBackup,
+ dokployRestart: input.dokployRestart,
+ dockerCleanup: input.dockerCleanup,
+ adminId: input.adminId,
+ })
+ .where(eq(notifications.notificationId, input.notificationId))
+ .returning()
+ .then((value) => value[0]);
+
+ if (!newDestination) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Error Updating notification",
+ });
+ }
+
+ await tx
+ .update(gotify)
+ .set({
+ serverUrl: input.serverUrl,
+ appToken: input.appToken,
+ priority: input.priority,
+ decoration: input.decoration,
+ })
+ .where(eq(gotify.gotifyId, input.gotifyId));
+
+ return newDestination;
+ });
+};
+
export const findNotificationById = async (notificationId: string) => {
const notification = await db.query.notifications.findFirst({
where: eq(notifications.notificationId, notificationId),
@@ -387,6 +480,7 @@ export const findNotificationById = async (notificationId: string) => {
telegram: true,
discord: true,
email: true,
+ gotify: true,
},
});
if (!notification) {
diff --git a/packages/server/src/utils/notifications/utils.ts b/packages/server/src/utils/notifications/utils.ts
index 2f8324bb..09fdd7bc 100644
--- a/packages/server/src/utils/notifications/utils.ts
+++ b/packages/server/src/utils/notifications/utils.ts
@@ -3,6 +3,7 @@ import type {
email,
slack,
telegram,
+ gotify,
} from "@dokploy/server/db/schema";
import nodemailer from "nodemailer";
@@ -87,3 +88,31 @@ export const sendSlackNotification = async (
console.log(err);
}
};
+
+export const sendGotifyNotification = async (
+ connection: typeof gotify.$inferInsert,
+ title: string,
+ message: string,
+) => {
+ const response = await fetch(`${connection.serverUrl}/message`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Gotify-Key": connection.appToken,
+ },
+ body: JSON.stringify({
+ title: title,
+ message: message,
+ priority: connection.priority,
+ extras: {
+ "client::display": {
+ "contentType": "text/plain"
+ }
+ }
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to send Gotify notification: ${response.statusText}`);
+ }
+};
\ No newline at end of file