feat(notifications): implement gotify provider

This commit is contained in:
depado
2025-01-10 20:07:43 +01:00
parent ad479c4be1
commit cc473b3e87
8 changed files with 1232 additions and 5 deletions

View File

@@ -28,7 +28,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, Mail, PenBoxIcon, PlusIcon } from "lucide-react"; import { AlertTriangle, Mail, PenBoxIcon, PlusIcon, MessageCircleMore } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form"; import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -84,6 +84,15 @@ export const notificationSchema = z.discriminatedUnion("type", [
.min(1, { message: "At least one email is required" }), .min(1, { message: "At least one email is required" }),
}) })
.merge(notificationBaseSchema), .merge(notificationBaseSchema),
z
.object({
type: z.literal("gotify"),
serverUrl: z.string().min(1, { message: "Server URL is required" }),
appToken: z.string().min(1, { message: "App Token is required" }),
priority: z.number().min(1).max(10).default(5),
decoration: z.boolean().default(true),
})
.merge(notificationBaseSchema),
]); ]);
export const notificationsMap = { export const notificationsMap = {
@@ -103,6 +112,10 @@ export const notificationsMap = {
icon: <Mail size={29} className="text-muted-foreground" />, icon: <Mail size={29} className="text-muted-foreground" />,
label: "Email", label: "Email",
}, },
gotify: {
icon: <MessageCircleMore size={29} className="text-muted-foreground" />,
label: "Gotify",
},
}; };
export type NotificationSchema = z.infer<typeof notificationSchema>; export type NotificationSchema = z.infer<typeof notificationSchema>;
@@ -126,13 +139,14 @@ export const HandleNotifications = ({ notificationId }: Props) => {
); );
const { mutateAsync: testSlackConnection, isLoading: isLoadingSlack } = const { mutateAsync: testSlackConnection, isLoading: isLoadingSlack } =
api.notification.testSlackConnection.useMutation(); api.notification.testSlackConnection.useMutation();
const { mutateAsync: testTelegramConnection, isLoading: isLoadingTelegram } = const { mutateAsync: testTelegramConnection, isLoading: isLoadingTelegram } =
api.notification.testTelegramConnection.useMutation(); api.notification.testTelegramConnection.useMutation();
const { mutateAsync: testDiscordConnection, isLoading: isLoadingDiscord } = const { mutateAsync: testDiscordConnection, isLoading: isLoadingDiscord } =
api.notification.testDiscordConnection.useMutation(); api.notification.testDiscordConnection.useMutation();
const { mutateAsync: testEmailConnection, isLoading: isLoadingEmail } = const { mutateAsync: testEmailConnection, isLoading: isLoadingEmail } =
api.notification.testEmailConnection.useMutation(); api.notification.testEmailConnection.useMutation();
const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } =
api.notification.testGotifyConnection.useMutation();
const slackMutation = notificationId const slackMutation = notificationId
? api.notification.updateSlack.useMutation() ? api.notification.updateSlack.useMutation()
: api.notification.createSlack.useMutation(); : api.notification.createSlack.useMutation();
@@ -145,6 +159,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const emailMutation = notificationId const emailMutation = notificationId
? api.notification.updateEmail.useMutation() ? api.notification.updateEmail.useMutation()
: api.notification.createEmail.useMutation(); : api.notification.createEmail.useMutation();
const gotifyMutation = notificationId
? api.notification.updateGotify.useMutation()
: api.notification.createGotify.useMutation();
const form = useForm<NotificationSchema>({ const form = useForm<NotificationSchema>({
defaultValues: { defaultValues: {
@@ -233,6 +250,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
telegram: telegramMutation, telegram: telegramMutation,
discord: discordMutation, discord: discordMutation,
email: emailMutation, email: emailMutation,
gotify: gotifyMutation,
}; };
const onSubmit = async (data: NotificationSchema) => { const onSubmit = async (data: NotificationSchema) => {
@@ -300,6 +318,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
notificationId: notificationId || "", notificationId: notificationId || "",
emailId: notification?.emailId || "", emailId: notification?.emailId || "",
}); });
} else if (data.type === "gotify") {
promise = gotifyMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
serverUrl: data.serverUrl,
appToken: data.appToken,
priority: data.priority,
name: data.name,
dockerCleanup: dockerCleanup,
decoration: data.decoration,
notificationId: notificationId || "",
gotifyId: notification?.gotifyId || "",
});
} }
if (promise) { if (promise) {
@@ -700,6 +733,94 @@ export const HandleNotifications = ({ notificationId }: Props) => {
</Button> </Button>
</> </>
)} )}
{type === "gotify" && (
<>
<FormField
control={form.control}
name="serverUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL</FormLabel>
<FormControl>
<Input
placeholder="https://gotify.example.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="appToken"
render={({ field }) => (
<FormItem>
<FormLabel>App Token</FormLabel>
<FormControl>
<Input
placeholder="AzxcvbnmKjhgfdsa..."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
defaultValue={5}
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Priority</FormLabel>
<FormControl>
<Input
placeholder="5"
{...field}
onChange={(e) => {
const value = e.target.value;
if (value) {
const port = Number.parseInt(value);
if (port > 0 && port < 10) {
field.onChange(port);
}
}
}}
type="number"
/>
</FormControl>
<FormDescription>
Message priority (1-10, default: 5)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="decoration"
defaultValue={true}
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Decoration</FormLabel>
<FormDescription>
Decorate the notification with emojis.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</>
)}
</div> </div>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -824,7 +945,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingSlack || isLoadingSlack ||
isLoadingTelegram || isLoadingTelegram ||
isLoadingDiscord || isLoadingDiscord ||
isLoadingEmail isLoadingEmail ||
isLoadingGotify
} }
variant="secondary" variant="secondary"
onClick={async () => { onClick={async () => {
@@ -853,6 +975,13 @@ export const HandleNotifications = ({ notificationId }: Props) => {
toAddresses: form.getValues("toAddresses"), toAddresses: form.getValues("toAddresses"),
fromAddress: form.getValues("fromAddress"), 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"); toast.success("Connection Success");
} catch (err) { } catch (err) {

View File

@@ -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<NotificationSchema>({
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<unknown> | 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 (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 dark:hover:bg-zinc-900/80 hover:bg-gray-200/80"
>
<Pen className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Update Notification</DialogTitle>
<DialogDescription>
Update the current notification config
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="flex flex-col gap-4 ">
<div className="flex flex-row gap-2 w-full items-center">
<div className="flex flex-row gap-2 items-center w-full ">
<FormLabel className="text-lg font-semibold leading-none tracking-tight flex">
{data?.notificationType === "slack"
? "Slack"
: data?.notificationType === "telegram"
? "Telegram"
: data?.notificationType === "discord"
? "Discord"
: data?.notificationType === "email"
? "Email"
: "Gotify"}
</FormLabel>
</div>
{data?.notificationType === "slack" && (
<SlackIcon className="text-muted-foreground size-6 flex-shrink-0" />
)}
{data?.notificationType === "telegram" && (
<TelegramIcon className="text-muted-foreground size-8 flex-shrink-0" />
)}
{data?.notificationType === "discord" && (
<DiscordIcon className="text-muted-foreground size-7 flex-shrink-0" />
)}
{data?.notificationType === "email" && (
<Mail
size={29}
className="text-muted-foreground size-6 flex-shrink-0"
/>
)}
{data?.notificationType === "gotify" && (
<Mail
size={29}
className="text-muted-foreground size-6 flex-shrink-0"
/>
)}
</div>
<div className="flex flex-col gap-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{type === "slack" && (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input
placeholder="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="channel"
render={({ field }) => (
<FormItem>
<FormLabel>Channel</FormLabel>
<FormControl>
<Input placeholder="Channel" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{type === "telegram" && (
<>
<FormField
control={form.control}
name="botToken"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Token</FormLabel>
<FormControl>
<Input
placeholder="6660491268:AAFMGmajZOVewpMNZCgJr5H7cpXpoZPgvXw"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="chatId"
render={({ field }) => (
<FormItem>
<FormLabel>Chat ID</FormLabel>
<FormControl>
<Input placeholder="431231869" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{type === "discord" && (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input
placeholder="https://discord.com/api/webhooks/123456789/ABCDEFGHIJKLMNOPQRSTUVWXYZ"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="decoration"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Decoration</FormLabel>
<FormDescription>
Decorate the notification with emojis.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</>
)}
{type === "email" && (
<>
<div className="flex md:flex-row flex-col gap-2 w-full">
<FormField
control={form.control}
name="smtpServer"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>SMTP Server</FormLabel>
<FormControl>
<Input placeholder="smtp.gmail.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="smtpPort"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>SMTP Port</FormLabel>
<FormControl>
<Input
placeholder="587"
{...field}
onChange={(e) => {
const value = e.target.value;
if (value) {
const port = Number.parseInt(value);
if (port > 0 && port < 65536) {
field.onChange(port);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex md:flex-row flex-col gap-2 w-full">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="******************"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="fromAddress"
render={({ field }) => (
<FormItem>
<FormLabel>From Address</FormLabel>
<FormControl>
<Input placeholder="from@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col gap-2 pt-2">
<FormLabel>To Addresses</FormLabel>
{fields.map((field, index) => (
<div
key={field.id}
className="flex flex-row gap-2 w-full"
>
<FormField
control={form.control}
name={`toAddresses.${index}`}
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Input
placeholder="email@example.com"
className="w-full"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="outline"
type="button"
onClick={() => {
remove(index);
}}
>
Remove
</Button>
</div>
))}
{type === "email" &&
"toAddresses" in form.formState.errors && (
<div className="text-sm font-medium text-destructive">
{form.formState?.errors?.toAddresses?.root?.message}
</div>
)}
</div>
<Button
variant="outline"
type="button"
onClick={() => {
append("");
}}
>
Add
</Button>
</>
)}
{type === "gotify" && (
<>
<FormField
control={form.control}
name="serverUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL</FormLabel>
<FormControl>
<Input
placeholder="https://gotify.example.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="appToken"
render={({ field }) => (
<FormItem>
<FormLabel>App Token</FormLabel>
<FormControl>
<Input
placeholder="AzxF2.d9KzP..."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
defaultValue={5}
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Priority</FormLabel>
<FormControl>
<Input
placeholder="5"
{...field}
onChange={(e) => {
const value = e.target.value;
if (value) {
const priority = Number.parseInt(value);
if (priority > 0 && priority < 10) {
field.onChange(priority);
}
}
}}
type="number"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="decoration"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Decoration</FormLabel>
<FormDescription>
Decorate the notification with emojis.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</>
)}
</div>
</div>
<div className="flex flex-col gap-4">
<FormLabel className="text-lg font-semibold leading-none tracking-tight">
Select the actions.
</FormLabel>
<div className="grid md:grid-cols-2 gap-4">
<FormField
control={form.control}
defaultValue={form.control._defaultValues.appDeploy}
name="appDeploy"
render={({ field }) => (
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
<div className="space-y-0.5">
<FormLabel>App Deploy</FormLabel>
<FormDescription>
Trigger the action when a app is deployed.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
defaultValue={form.control._defaultValues.appBuildError}
name="appBuildError"
render={({ field }) => (
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
<div className="space-y-0.5">
<FormLabel>App Builder Error</FormLabel>
<FormDescription>
Trigger the action when the build fails.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="databaseBackup"
defaultValue={form.control._defaultValues.databaseBackup}
render={({ field }) => (
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
<div className="space-y-0.5">
<FormLabel>Database Backup</FormLabel>
<FormDescription>
Trigger the action when a database backup is created.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="dockerCleanup"
render={({ field }) => (
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
<div className="space-y-0.5">
<FormLabel>Docker Cleanup</FormLabel>
<FormDescription>
Trigger the action when the docker cleanup is
performed.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{!isCloud && (
<FormField
control={form.control}
defaultValue={form.control._defaultValues.dokployRestart}
name="dokployRestart"
render={({ field }) => (
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
<div className="space-y-0.5">
<FormLabel>Dokploy Restart</FormLabel>
<FormDescription>
Trigger the action when a dokploy is restarted.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
</div>
</div>
</form>
<DialogFooter className="flex flex-row gap-2 !justify-between w-full">
<Button
isLoading={
isLoadingSlack ||
isLoadingTelegram ||
isLoadingDiscord ||
isLoadingEmail ||
isLoadingGotify
}
variant="secondary"
onClick={async () => {
try {
if (type === "slack") {
await testSlackConnection({
webhookUrl: form.getValues("webhookUrl"),
channel: form.getValues("channel"),
});
} else if (type === "telegram") {
await testTelegramConnection({
botToken: form.getValues("botToken"),
chatId: form.getValues("chatId"),
});
} else if (type === "discord") {
await testDiscordConnection({
webhookUrl: form.getValues("webhookUrl"),
decoration: form.getValues("decoration"),
});
} else if (type === "email") {
await testEmailConnection({
smtpServer: form.getValues("smtpServer"),
smtpPort: form.getValues("smtpPort"),
username: form.getValues("username"),
password: form.getValues("password"),
toAddresses: form.getValues("toAddresses"),
fromAddress: form.getValues("fromAddress"),
});
} else if (type === "gotify") {
await testGotifyConnection({
priority: form.getValues("priority"),
serverUrl: form.getValues("serverUrl"),
appToken: form.getValues("appToken"),
decoration: form.getValues("decoration"),
});
}
toast.success("Connection Success");
} catch (err) {
toast.error("Error testing the provider");
}
}}
>
Test Notification
</Button>
<Button
isLoading={form.formState.isSubmitting}
form="hook-form"
type="submit"
>
Update
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -4250,4 +4250,4 @@
"schemas": {}, "schemas": {},
"tables": {} "tables": {}
} }
} }

View File

@@ -395,4 +395,4 @@
"breakpoints": true "breakpoints": true
} }
] ]
} }

View File

@@ -2,20 +2,24 @@ import {
adminProcedure, adminProcedure,
createTRPCRouter, createTRPCRouter,
protectedProcedure, protectedProcedure,
publicProcedure,
} from "@/server/api/trpc"; } from "@/server/api/trpc";
import { db } from "@/server/db"; import { db } from "@/server/db";
import { import {
apiCreateDiscord, apiCreateDiscord,
apiCreateEmail, apiCreateEmail,
apiCreateGotify,
apiCreateSlack, apiCreateSlack,
apiCreateTelegram, apiCreateTelegram,
apiFindOneNotification, apiFindOneNotification,
apiTestDiscordConnection, apiTestDiscordConnection,
apiTestEmailConnection, apiTestEmailConnection,
apiTestGotifyConnection,
apiTestSlackConnection, apiTestSlackConnection,
apiTestTelegramConnection, apiTestTelegramConnection,
apiUpdateDiscord, apiUpdateDiscord,
apiUpdateEmail, apiUpdateEmail,
apiUpdateGotify,
apiUpdateSlack, apiUpdateSlack,
apiUpdateTelegram, apiUpdateTelegram,
notifications, notifications,
@@ -24,16 +28,19 @@ import {
IS_CLOUD, IS_CLOUD,
createDiscordNotification, createDiscordNotification,
createEmailNotification, createEmailNotification,
createGotifyNotification,
createSlackNotification, createSlackNotification,
createTelegramNotification, createTelegramNotification,
findNotificationById, findNotificationById,
removeNotificationById, removeNotificationById,
sendDiscordNotification, sendDiscordNotification,
sendEmailNotification, sendEmailNotification,
sendGotifyNotification,
sendSlackNotification, sendSlackNotification,
sendTelegramNotification, sendTelegramNotification,
updateDiscordNotification, updateDiscordNotification,
updateEmailNotification, updateEmailNotification,
updateGotifyNotification,
updateSlackNotification, updateSlackNotification,
updateTelegramNotification, updateTelegramNotification,
} from "@dokploy/server"; } from "@dokploy/server";
@@ -300,10 +307,61 @@ export const notificationRouter = createTRPCRouter({
telegram: true, telegram: true,
discord: true, discord: true,
email: true, email: true,
gotify: true,
}, },
orderBy: desc(notifications.createdAt), orderBy: desc(notifications.createdAt),
...(IS_CLOUD && { where: eq(notifications.adminId, ctx.user.adminId) }), ...(IS_CLOUD && { where: eq(notifications.adminId, ctx.user.adminId) }),
// TODO: Remove this line when the cloud version is ready // 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,
});
}
}),
}); });

View File

@@ -10,6 +10,7 @@ export const notificationType = pgEnum("notificationType", [
"telegram", "telegram",
"discord", "discord",
"email", "email",
"gotify",
]); ]);
export const notifications = pgTable("notification", { export const notifications = pgTable("notification", {
@@ -39,6 +40,9 @@ export const notifications = pgTable("notification", {
emailId: text("emailId").references(() => email.emailId, { emailId: text("emailId").references(() => email.emailId, {
onDelete: "cascade", onDelete: "cascade",
}), }),
gotifyId: text("gotifyId").references(() => gotify.gotifyId, {
onDelete: "cascade",
}),
adminId: text("adminId").references(() => admins.adminId, { adminId: text("adminId").references(() => admins.adminId, {
onDelete: "cascade", onDelete: "cascade",
}), }),
@@ -84,6 +88,17 @@ export const email = pgTable("email", {
toAddresses: text("toAddress").array().notNull(), 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 }) => ({ export const notificationsRelations = relations(notifications, ({ one }) => ({
slack: one(slack, { slack: one(slack, {
fields: [notifications.slackId], fields: [notifications.slackId],
@@ -101,6 +116,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
fields: [notifications.emailId], fields: [notifications.emailId],
references: [email.emailId], references: [email.emailId],
}), }),
gotify: one(gotify, {
fields: [notifications.gotifyId],
references: [gotify.gotifyId],
}),
admin: one(admins, { admin: one(admins, {
fields: [notifications.adminId], fields: [notifications.adminId],
references: [admins.adminId], references: [admins.adminId],
@@ -224,6 +243,37 @@ export const apiTestEmailConnection = apiCreateEmail.pick({
fromAddress: true, 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 export const apiFindOneNotification = notificationsSchema
.pick({ .pick({
notificationId: true, notificationId: true,
@@ -242,5 +292,8 @@ export const apiSendTest = notificationsSchema
username: z.string(), username: z.string(),
password: z.string(), password: z.string(),
toAddresses: z.array(z.string()), toAddresses: z.array(z.string()),
serverUrl: z.string(),
appToken: z.string(),
priority: z.number(),
}) })
.partial(); .partial();

View File

@@ -2,14 +2,17 @@ import { db } from "@dokploy/server/db";
import { import {
type apiCreateDiscord, type apiCreateDiscord,
type apiCreateEmail, type apiCreateEmail,
type apiCreateGotify,
type apiCreateSlack, type apiCreateSlack,
type apiCreateTelegram, type apiCreateTelegram,
type apiUpdateDiscord, type apiUpdateDiscord,
type apiUpdateEmail, type apiUpdateEmail,
type apiUpdateGotify,
type apiUpdateSlack, type apiUpdateSlack,
type apiUpdateTelegram, type apiUpdateTelegram,
discord, discord,
email, email,
gotify,
notifications, notifications,
slack, slack,
telegram, 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) => { export const findNotificationById = async (notificationId: string) => {
const notification = await db.query.notifications.findFirst({ const notification = await db.query.notifications.findFirst({
where: eq(notifications.notificationId, notificationId), where: eq(notifications.notificationId, notificationId),
@@ -387,6 +480,7 @@ export const findNotificationById = async (notificationId: string) => {
telegram: true, telegram: true,
discord: true, discord: true,
email: true, email: true,
gotify: true,
}, },
}); });
if (!notification) { if (!notification) {

View File

@@ -3,6 +3,7 @@ import type {
email, email,
slack, slack,
telegram, telegram,
gotify,
} from "@dokploy/server/db/schema"; } from "@dokploy/server/db/schema";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
@@ -87,3 +88,31 @@ export const sendSlackNotification = async (
console.log(err); 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}`);
}
};