Merge pull request #1081 from depado/gotify-notifications

feat(notifications): implement gotify provider
This commit is contained in:
Mauricio Siu
2025-01-18 17:33:55 -06:00
committed by GitHub
14 changed files with 4817 additions and 10 deletions

View File

@@ -28,7 +28,13 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, Mail, PenBoxIcon, PlusIcon } from "lucide-react";
import {
AlertTriangle,
Mail,
MessageCircleMore,
PenBoxIcon,
PlusIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -84,6 +90,15 @@ export const notificationSchema = z.discriminatedUnion("type", [
.min(1, { message: "At least one email is required" }),
})
.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 = {
@@ -103,6 +118,10 @@ export const notificationsMap = {
icon: <Mail size={29} className="text-muted-foreground" />,
label: "Email",
},
gotify: {
icon: <MessageCircleMore size={29} className="text-muted-foreground" />,
label: "Gotify",
},
};
export type NotificationSchema = z.infer<typeof notificationSchema>;
@@ -126,13 +145,14 @@ export const HandleNotifications = ({ notificationId }: Props) => {
);
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 = notificationId
? api.notification.updateSlack.useMutation()
: api.notification.createSlack.useMutation();
@@ -145,6 +165,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const emailMutation = notificationId
? api.notification.updateEmail.useMutation()
: api.notification.createEmail.useMutation();
const gotifyMutation = notificationId
? api.notification.updateGotify.useMutation()
: api.notification.createGotify.useMutation();
const form = useForm<NotificationSchema>({
defaultValues: {
@@ -222,6 +245,20 @@ export const HandleNotifications = ({ notificationId }: Props) => {
name: notification.name,
dockerCleanup: notification.dockerCleanup,
});
} else if (notification.notificationType === "gotify") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
type: notification.notificationType,
appToken: notification.gotify?.appToken,
decoration: notification.gotify?.decoration || undefined,
priority: notification.gotify?.priority,
serverUrl: notification.gotify?.serverUrl,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
});
}
} else {
form.reset();
@@ -233,6 +270,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
telegram: telegramMutation,
discord: discordMutation,
email: emailMutation,
gotify: gotifyMutation,
};
const onSubmit = async (data: NotificationSchema) => {
@@ -300,6 +338,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
notificationId: notificationId || "",
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) {
@@ -700,6 +753,94 @@ export const HandleNotifications = ({ notificationId }: Props) => {
</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 className="flex flex-col gap-4">
@@ -824,7 +965,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingSlack ||
isLoadingTelegram ||
isLoadingDiscord ||
isLoadingEmail
isLoadingEmail ||
isLoadingGotify
}
variant="secondary"
onClick={async () => {
@@ -853,6 +995,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) {

View File

@@ -13,7 +13,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { Bell, Loader2, Mail, Trash2 } from "lucide-react";
import { Bell, Loader2, Mail, MessageCircleMore, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { HandleNotifications } from "./handle-notifications";
@@ -83,6 +83,11 @@ export const ShowNotifications = () => {
<Mail className="size-6 text-muted-foreground" />
</div>
)}
{notification.notificationType === "gotify" && (
<div className="flex items-center justify-center rounded-lg ">
<MessageCircleMore className="size-6 text-muted-foreground" />
</div>
)}
{notification.name}
</span>

View File

@@ -0,0 +1,15 @@
ALTER TYPE "notificationType" ADD VALUE 'gotify';--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "gotify" (
"gotifyId" text PRIMARY KEY NOT NULL,
"serverUrl" text NOT NULL,
"appToken" text NOT NULL,
"priority" integer DEFAULT 5 NOT NULL,
"decoration" boolean
);
--> statement-breakpoint
ALTER TABLE "notification" ADD COLUMN "gotifyId" text;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "notification" ADD CONSTRAINT "notification_gotifyId_gotify_gotifyId_fk" FOREIGN KEY ("gotifyId") REFERENCES "public"."gotify"("gotifyId") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -393,6 +393,13 @@
"when": 1736669623831,
"tag": "0055_next_serpent_society",
"breakpoints": true
},
{
"idx": 56,
"version": "6",
"when": 1736789918294,
"tag": "0056_majestic_skaar",
"breakpoints": true
}
]
}

View File

@@ -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,
});
}
}),
});