feat: add generic webhook form & refactor each type in its own form (#239)

* feat: add generic webhook form & refactor each type in its own form

* chore: pr feedbacks

* fix(email-form): filter out empty emails
This commit is contained in:
Nico 2026-01-02 14:56:35 +01:00 committed by GitHub
parent b0c397ab87
commit 658c42a53b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1019 additions and 568 deletions

View file

@ -2151,6 +2151,15 @@ export type GetScheduleNotificationsResponses = {
useTLS: boolean;
password?: string;
username?: string;
} | {
method: 'GET' | 'POST';
type: 'generic';
url: string;
contentType?: string;
headers?: Array<string>;
messageKey?: string;
titleKey?: string;
useJson?: boolean;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
@ -2184,7 +2193,7 @@ export type GetScheduleNotificationsResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
destinationId: number;
@ -2241,6 +2250,15 @@ export type UpdateScheduleNotificationsResponses = {
useTLS: boolean;
password?: string;
username?: string;
} | {
method: 'GET' | 'POST';
type: 'generic';
url: string;
contentType?: string;
headers?: Array<string>;
messageKey?: string;
titleKey?: string;
useJson?: boolean;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
@ -2274,7 +2292,7 @@ export type UpdateScheduleNotificationsResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
destinationId: number;
@ -2568,6 +2586,15 @@ export type ListNotificationDestinationsResponses = {
useTLS: boolean;
password?: string;
username?: string;
} | {
method: 'GET' | 'POST';
type: 'generic';
url: string;
contentType?: string;
headers?: Array<string>;
messageKey?: string;
titleKey?: string;
useJson?: boolean;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
@ -2601,7 +2628,7 @@ export type ListNotificationDestinationsResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
}>;
};
@ -2629,6 +2656,15 @@ export type CreateNotificationDestinationData = {
useTLS: boolean;
password?: string;
username?: string;
} | {
method: 'GET' | 'POST';
type: 'generic';
url: string;
contentType?: string;
headers?: Array<string>;
messageKey?: string;
titleKey?: string;
useJson?: boolean;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
@ -2689,6 +2725,15 @@ export type CreateNotificationDestinationResponses = {
useTLS: boolean;
password?: string;
username?: string;
} | {
method: 'GET' | 'POST';
type: 'generic';
url: string;
contentType?: string;
headers?: Array<string>;
messageKey?: string;
titleKey?: string;
useJson?: boolean;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
@ -2722,7 +2767,7 @@ export type CreateNotificationDestinationResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
};
@ -2796,6 +2841,15 @@ export type GetNotificationDestinationResponses = {
useTLS: boolean;
password?: string;
username?: string;
} | {
method: 'GET' | 'POST';
type: 'generic';
url: string;
contentType?: string;
headers?: Array<string>;
messageKey?: string;
titleKey?: string;
useJson?: boolean;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
@ -2829,7 +2883,7 @@ export type GetNotificationDestinationResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
};
@ -2857,6 +2911,15 @@ export type UpdateNotificationDestinationData = {
useTLS: boolean;
password?: string;
username?: string;
} | {
method: 'GET' | 'POST';
type: 'generic';
url: string;
contentType?: string;
headers?: Array<string>;
messageKey?: string;
titleKey?: string;
useJson?: boolean;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
@ -2927,6 +2990,15 @@ export type UpdateNotificationDestinationResponses = {
useTLS: boolean;
password?: string;
username?: string;
} | {
method: 'GET' | 'POST';
type: 'generic';
url: string;
contentType?: string;
headers?: Array<string>;
messageKey?: string;
titleKey?: string;
useJson?: boolean;
} | {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
@ -2960,7 +3032,7 @@ export type UpdateNotificationDestinationResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
type: 'custom' | 'discord' | 'email' | 'generic' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
};

View file

@ -14,10 +14,19 @@ import {
FormMessage,
} from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { SecretInput } from "~/client/components/ui/secret-input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Checkbox } from "~/client/components/ui/checkbox";
import { notificationConfigSchemaBase } from "~/schemas/notifications";
import {
CustomForm,
DiscordForm,
EmailForm,
GenericForm,
GotifyForm,
NtfyForm,
PushoverForm,
SlackForm,
TelegramForm,
} from "./notification-forms";
export const formSchema = type({
name: "2<=string<=32",
@ -48,6 +57,9 @@ const defaultValuesForType = {
slack: {
type: "slack" as const,
webhookUrl: "",
channel: "",
username: "",
iconEmoji: "",
},
discord: {
type: "discord" as const,
@ -75,6 +87,16 @@ const defaultValuesForType = {
botToken: "",
chatId: "",
},
generic: {
type: "generic" as const,
url: "",
method: "POST" as const,
contentType: "application/json",
headers: [],
useJson: true,
titleKey: "title",
messageKey: "message",
},
custom: {
type: "custom" as const,
shoutrrrUrl: "",
@ -84,7 +106,9 @@ const defaultValuesForType = {
export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValues, formId, className }: Props) => {
const form = useForm<NotificationFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
defaultValues: initialValues,
defaultValues: initialValues || {
name: "",
},
resetOptions: {
keepDefaultValues: true,
keepDirtyValues: false,
@ -97,7 +121,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
useEffect(() => {
if (!initialValues) {
form.reset({
name: form.getValues().name,
name: form.getValues().name || "",
...defaultValuesForType[watchedType as keyof typeof defaultValuesForType],
});
}
@ -152,6 +176,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
<SelectItem value="ntfy">Ntfy</SelectItem>
<SelectItem value="pushover">Pushover</SelectItem>
<SelectItem value="telegram">Telegram</SelectItem>
<SelectItem value="generic">Generic Webhook</SelectItem>
<SelectItem value="custom">Custom (Shoutrrr URL)</SelectItem>
</SelectContent>
</Select>
@ -161,545 +186,15 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
)}
/>
{watchedType === "email" && (
<>
<FormField
control={form.control}
name="smtpHost"
render={({ field }) => (
<FormItem>
<FormLabel>SMTP Host</FormLabel>
<FormControl>
<Input {...field} placeholder="smtp.example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="smtpPort"
render={({ field }) => (
<FormItem>
<FormLabel>SMTP Port</FormLabel>
<FormControl>
<Input
{...field}
type="number"
placeholder="587"
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="user@example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="from"
render={({ field }) => (
<FormItem>
<FormLabel>From Address</FormLabel>
<FormControl>
<Input {...field} placeholder="noreply@example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="to"
render={({ field }) => (
<FormItem>
<FormLabel>To Addresses</FormLabel>
<FormControl>
<Input
{...field}
placeholder="user@example.com, admin@example.com"
value={Array.isArray(field.value) ? field.value.join(", ") : ""}
onChange={(e) => field.onChange(e.target.value.split(",").map((email) => email.trim()))}
/>
</FormControl>
<FormDescription>Comma-separated list of recipient email addresses.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="useTLS"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Use TLS</FormLabel>
<FormDescription>Enable TLS encryption for SMTP connection.</FormDescription>
</div>
</FormItem>
)}
/>
</>
)}
{watchedType === "slack" && (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX"
/>
</FormControl>
<FormDescription>Get this from your Slack app's Incoming Webhooks settings.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="channel"
render={({ field }) => (
<FormItem>
<FormLabel>Channel (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="#backups" />
</FormControl>
<FormDescription>Override the default channel (use # for channels, @ for users).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="Zerobyte" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="iconEmoji"
render={({ field }) => (
<FormItem>
<FormLabel>Icon Emoji (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder=":floppy_disk:" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "discord" && (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN" />
</FormControl>
<FormDescription>Get this from your Discord server's Integrations settings.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="Zerobyte" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="avatarUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Avatar URL (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="https://example.com/avatar.png" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="threadId"
render={({ field }) => (
<FormItem>
<FormLabel>Thread ID (Optional)</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
ID of the thread to post messages in. Leave empty to post in the main channel.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "gotify" && (
<>
<FormField
control={form.control}
name="serverUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://gotify.example.com" />
</FormControl>
<FormDescription>Your self-hosted Gotify server URL.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="token"
render={({ field }) => (
<FormItem>
<FormLabel>App Token</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormDescription>Application token from Gotify.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={0}
max={10}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormDescription>Priority level (0-10, where 10 is highest).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="/custom/path" />
</FormControl>
<FormDescription>Custom path on the Gotify server, if applicable.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "ntfy" && (
<>
<FormField
control={form.control}
name="serverUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="https://ntfy.example.com" />
</FormControl>
<FormDescription>Leave empty to use ntfy.sh public service.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="topic"
render={({ field }) => (
<FormItem>
<FormLabel>Topic</FormLabel>
<FormControl>
<Input {...field} placeholder="ironmount-backups" />
</FormControl>
<FormDescription>The ntfy topic name to publish to.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="username" />
</FormControl>
<FormDescription>Username for server authentication, if required.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormDescription>Password for server authentication, if required.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessToken"
render={({ field }) => (
<FormItem>
<FormLabel>Access token (Optional)</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormDescription>
Access token for server authentication. Will take precedence over username/password if set.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<Select onValueChange={field.onChange} defaultValue={String(field.value)} value={String(field.value)}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="max">Max (5)</SelectItem>
<SelectItem value="high">High (4)</SelectItem>
<SelectItem value="default">Default (3)</SelectItem>
<SelectItem value="low">Low (2)</SelectItem>
<SelectItem value="min">Min (1)</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "pushover" && (
<>
<FormField
control={form.control}
name="userKey"
render={({ field }) => (
<FormItem>
<FormLabel>User Key</FormLabel>
<FormControl>
<Input {...field} placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" />
</FormControl>
<FormDescription>Your Pushover user key from the dashboard.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiToken"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormDescription>Application API token from your Pushover application.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="devices"
render={({ field }) => (
<FormItem>
<FormLabel>Devices (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="iphone,android" />
</FormControl>
<FormDescription>Comma-separated list of device names. Leave empty for all devices.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<Select
onValueChange={(value) => field.onChange(Number(value))}
defaultValue={String(field.value)}
value={String(field.value)}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="-1">Low (-1)</SelectItem>
<SelectItem value="0">Normal (0)</SelectItem>
<SelectItem value="1">High (1)</SelectItem>
</SelectContent>
</Select>
<FormDescription>Message priority level.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "telegram" && (
<>
<FormField
control={form.control}
name="botToken"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Token</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" />
</FormControl>
<FormDescription>
Telegram bot token. Get this from BotFather when you create your bot.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="chatId"
render={({ field }) => (
<FormItem>
<FormLabel>Chat ID</FormLabel>
<FormControl>
<Input {...field} placeholder="-1231234567890" />
</FormControl>
<FormDescription>Telegram chat ID to send notifications to.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "custom" && (
<FormField
control={form.control}
name="shoutrrrUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Shoutrrr URL</FormLabel>
<FormControl>
<Input
{...field}
placeholder="smtp://user:pass@smtp.gmail.com:587/?from=you@gmail.com&to=recipient@example.com"
/>
</FormControl>
<FormDescription>
Direct Shoutrrr URL for power users. See&nbsp;
<a
href="https://shoutrrr.nickfedor.com/v0.12.0/services/overview/"
target="_blank"
rel="noopener noreferrer"
className="text-strong-accent hover:underline"
>
Shoutrrr documentation
</a>
&nbsp;for supported services and URL formats.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{watchedType === "email" && <EmailForm form={form} />}
{watchedType === "slack" && <SlackForm form={form} />}
{watchedType === "discord" && <DiscordForm form={form} />}
{watchedType === "gotify" && <GotifyForm form={form} />}
{watchedType === "ntfy" && <NtfyForm form={form} />}
{watchedType === "pushover" && <PushoverForm form={form} />}
{watchedType === "telegram" && <TelegramForm form={form} />}
{watchedType === "generic" && <GenericForm form={form} />}
{watchedType === "custom" && <CustomForm form={form} />}
</form>
</Form>
);

View file

@ -0,0 +1,41 @@
import type { UseFormReturn } from "react-hook-form";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import type { NotificationFormValues } from "../create-notification-form";
type Props = {
form: UseFormReturn<NotificationFormValues>;
};
export const CustomForm = ({ form }: Props) => {
return (
<FormField
control={form.control}
name="shoutrrrUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Shoutrrr URL</FormLabel>
<FormControl>
<Input
{...field}
placeholder="smtp://user:pass@smtp.gmail.com:587/?from=you@gmail.com&to=recipient@example.com"
/>
</FormControl>
<FormDescription>
Direct Shoutrrr URL for power users. See&nbsp;
<a
href="https://shoutrrr.nickfedor.com/v0.12.0/services/overview/"
target="_blank"
rel="noopener noreferrer"
className="text-strong-accent hover:underline"
>
Shoutrrr documentation
</a>
&nbsp;for supported services and URL formats.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
};

View file

@ -0,0 +1,71 @@
import type { UseFormReturn } from "react-hook-form";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import type { NotificationFormValues } from "../create-notification-form";
type Props = {
form: UseFormReturn<NotificationFormValues>;
};
export const DiscordForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN" />
</FormControl>
<FormDescription>Get this from your Discord server's Integrations settings.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="Zerobyte" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="avatarUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Avatar URL (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="https://example.com/avatar.png" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="threadId"
render={({ field }) => (
<FormItem>
<FormLabel>Thread ID (Optional)</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
ID of the thread to post messages in. Leave empty to post in the main channel.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -0,0 +1,128 @@
import type { UseFormReturn } from "react-hook-form";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { SecretInput } from "~/client/components/ui/secret-input";
import { Checkbox } from "~/client/components/ui/checkbox";
import type { NotificationFormValues } from "../create-notification-form";
type Props = {
form: UseFormReturn<NotificationFormValues>;
};
export const EmailForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="smtpHost"
render={({ field }) => (
<FormItem>
<FormLabel>SMTP Host</FormLabel>
<FormControl>
<Input {...field} placeholder="smtp.example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="smtpPort"
render={({ field }) => (
<FormItem>
<FormLabel>SMTP Port</FormLabel>
<FormControl>
<Input
{...field}
type="number"
placeholder="587"
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="user@example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="from"
render={({ field }) => (
<FormItem>
<FormLabel>From Address</FormLabel>
<FormControl>
<Input {...field} placeholder="noreply@example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="to"
render={({ field }) => (
<FormItem>
<FormLabel>To Addresses</FormLabel>
<FormControl>
<Input
{...field}
placeholder="user@example.com, admin@example.com"
value={Array.isArray(field.value) ? field.value.join(", ") : ""}
onChange={(e) =>
field.onChange(
e.target.value
.split(",")
.map((email) => email.trim())
.filter(Boolean),
)
}
/>
</FormControl>
<FormDescription>Comma-separated list of recipient email addresses.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="useTLS"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Use TLS</FormLabel>
<FormDescription>Enable TLS encryption for SMTP connection.</FormDescription>
</div>
</FormItem>
)}
/>
</>
);
};

View file

@ -0,0 +1,173 @@
import type { UseFormReturn } from "react-hook-form";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Checkbox } from "~/client/components/ui/checkbox";
import { Textarea } from "~/client/components/ui/textarea";
import { CodeBlock } from "~/client/components/ui/code-block";
import { Label } from "~/client/components/ui/label";
import type { NotificationFormValues } from "../create-notification-form";
type Props = {
form: UseFormReturn<NotificationFormValues>;
};
const WebhookPreview = ({ values }: { values: Partial<NotificationFormValues> }) => {
if (values.type !== "generic") return null;
const contentType = values.contentType || "application/json";
const headers = values.headers || [];
const useJson = values.useJson;
const titleKey = values.titleKey || "title";
const messageKey = values.messageKey || "message";
let body = "";
if (useJson) {
body = JSON.stringify(
{
[titleKey]: "Notification title",
[messageKey]: "Notification message",
},
null,
2,
);
} else {
body = "Notification message";
}
const previewCode = `${values.method} ${values.url}\nContent-Type: ${contentType}${headers.length > 0 ? `\n${headers.join("\n")}` : ""}
${body}`;
return (
<div className="space-y-2 pt-4 border-t">
<Label>Request Preview</Label>
<CodeBlock code={previewCode} filename="HTTP Request" />
<p className="text-[0.8rem] text-muted-foreground">This is a preview of the HTTP request that will be sent.</p>
</div>
);
};
export const GenericForm = ({ form }: Props) => {
const watchedValues = form.watch();
return (
<>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://api.example.com/webhook" />
</FormControl>
<FormDescription>The target URL for the webhook.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="method"
render={({ field }) => (
<FormItem>
<FormLabel>Method</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select method" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contentType"
render={({ field }) => (
<FormItem>
<FormLabel>Content Type</FormLabel>
<FormControl>
<Input {...field} placeholder="application/json" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="headers"
render={({ field }) => (
<FormItem>
<FormLabel>Headers</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="Authorization: Bearer token&#10;X-Custom-Header: value"
value={Array.isArray(field.value) ? field.value.join("\n") : ""}
onChange={(e) => field.onChange(e.target.value.split("\n"))}
/>
</FormControl>
<FormDescription>One header per line in Key: Value format.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="useJson"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Use JSON Template</FormLabel>
<FormDescription>Send the message as a JSON object.</FormDescription>
</div>
</FormItem>
)}
/>
{form.watch("useJson") && (
<>
<FormField
control={form.control}
name="titleKey"
render={({ field }) => (
<FormItem>
<FormLabel>Title Key</FormLabel>
<FormControl>
<Input {...field} placeholder="title" />
</FormControl>
<FormDescription>The JSON key for the notification title.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="messageKey"
render={({ field }) => (
<FormItem>
<FormLabel>Message Key</FormLabel>
<FormControl>
<Input {...field} placeholder="message" />
</FormControl>
<FormDescription>The JSON key for the notification message.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<WebhookPreview values={watchedValues} />
</>
);
};

View file

@ -0,0 +1,78 @@
import type { UseFormReturn } from "react-hook-form";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { SecretInput } from "~/client/components/ui/secret-input";
import type { NotificationFormValues } from "../create-notification-form";
type Props = {
form: UseFormReturn<NotificationFormValues>;
};
export const GotifyForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="serverUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://gotify.example.com" />
</FormControl>
<FormDescription>Your self-hosted Gotify server URL.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="token"
render={({ field }) => (
<FormItem>
<FormLabel>App Token</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormDescription>Application token from Gotify.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={0}
max={10}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormDescription>Priority level (0-10, where 10 is highest).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="/custom/path" />
</FormControl>
<FormDescription>Custom path on the Gotify server, if applicable.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -0,0 +1,9 @@
export * from "./email-form";
export * from "./slack-form";
export * from "./discord-form";
export * from "./gotify-form";
export * from "./ntfy-form";
export * from "./pushover-form";
export * from "./telegram-form";
export * from "./generic-form";
export * from "./custom-form";

View file

@ -0,0 +1,113 @@
import type { UseFormReturn } from "react-hook-form";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { SecretInput } from "~/client/components/ui/secret-input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import type { NotificationFormValues } from "../create-notification-form";
type Props = {
form: UseFormReturn<NotificationFormValues>;
};
export const NtfyForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="serverUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="https://ntfy.example.com" />
</FormControl>
<FormDescription>Leave empty to use ntfy.sh public service.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="topic"
render={({ field }) => (
<FormItem>
<FormLabel>Topic</FormLabel>
<FormControl>
<Input {...field} placeholder="ironmount-backups" />
</FormControl>
<FormDescription>The ntfy topic name to publish to.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="username" />
</FormControl>
<FormDescription>Username for server authentication, if required.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormDescription>Password for server authentication, if required.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessToken"
render={({ field }) => (
<FormItem>
<FormLabel>Access token (Optional)</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormDescription>
Access token for server authentication. Will take precedence over username/password if set.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<Select onValueChange={field.onChange} defaultValue={String(field.value)} value={String(field.value)}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="max">Max (5)</SelectItem>
<SelectItem value="high">High (4)</SelectItem>
<SelectItem value="default">Default (3)</SelectItem>
<SelectItem value="low">Low (2)</SelectItem>
<SelectItem value="min">Min (1)</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -0,0 +1,86 @@
import type { UseFormReturn } from "react-hook-form";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { SecretInput } from "~/client/components/ui/secret-input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import type { NotificationFormValues } from "../create-notification-form";
type Props = {
form: UseFormReturn<NotificationFormValues>;
};
export const PushoverForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="userKey"
render={({ field }) => (
<FormItem>
<FormLabel>User Key</FormLabel>
<FormControl>
<Input {...field} placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" />
</FormControl>
<FormDescription>Your Pushover user key from the dashboard.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiToken"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="••••••••" />
</FormControl>
<FormDescription>Application API token from your Pushover application.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="devices"
render={({ field }) => (
<FormItem>
<FormLabel>Devices (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="iphone,android" />
</FormControl>
<FormDescription>Comma-separated list of device names. Leave empty for all devices.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<Select
onValueChange={(value) => field.onChange(Number(value))}
defaultValue={String(field.value)}
value={String(field.value)}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="-1">Low (-1)</SelectItem>
<SelectItem value="0">Normal (0)</SelectItem>
<SelectItem value="1">High (1)</SelectItem>
</SelectContent>
</Select>
<FormDescription>Message priority level.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -0,0 +1,72 @@
import type { UseFormReturn } from "react-hook-form";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import type { NotificationFormValues } from "../create-notification-form";
type Props = {
form: UseFormReturn<NotificationFormValues>;
};
export const SlackForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX"
/>
</FormControl>
<FormDescription>Get this from your Slack app's Incoming Webhooks settings.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="channel"
render={({ field }) => (
<FormItem>
<FormLabel>Channel (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="#backups" />
</FormControl>
<FormDescription>Override the default channel (use # for channels, @ for users).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="Zerobyte" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="iconEmoji"
render={({ field }) => (
<FormItem>
<FormLabel>Icon Emoji (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder=":floppy_disk:" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -0,0 +1,44 @@
import type { UseFormReturn } from "react-hook-form";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { SecretInput } from "~/client/components/ui/secret-input";
import type { NotificationFormValues } from "../create-notification-form";
type Props = {
form: UseFormReturn<NotificationFormValues>;
};
export const TelegramForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="botToken"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Token</FormLabel>
<FormControl>
<SecretInput {...field} placeholder="123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" />
</FormControl>
<FormDescription>Telegram bot token. Get this from BotFather when you create your bot.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="chatId"
render={({ field }) => (
<FormItem>
<FormLabel>Chat ID</FormLabel>
<FormControl>
<Input {...field} placeholder="-1231234567890" />
</FormControl>
<FormDescription>Telegram chat ID to send notifications to.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
);
};

View file

@ -8,6 +8,7 @@ export const NOTIFICATION_TYPES = {
ntfy: "ntfy",
pushover: "pushover",
telegram: "telegram",
generic: "generic",
custom: "custom",
} as const;
@ -72,6 +73,17 @@ export const telegramNotificationConfigSchema = type({
chatId: "string",
});
export const genericNotificationConfigSchema = type({
type: "'generic'",
url: "string",
method: "'GET' | 'POST'",
contentType: "string?",
headers: "string[]?",
useJson: "boolean?",
titleKey: "string?",
messageKey: "string?",
});
export const customNotificationConfigSchema = type({
type: "'custom'",
shoutrrrUrl: "string",
@ -84,6 +96,7 @@ export const notificationConfigSchemaBase = emailNotificationConfigSchema
.or(ntfyNotificationConfigSchema)
.or(pushoverNotificationConfigSchema)
.or(telegramNotificationConfigSchema)
.or(genericNotificationConfigSchema)
.or(customNotificationConfigSchema);
export const notificationConfigSchema = notificationConfigSchemaBase.onUndeclaredKey("delete");

View file

@ -1,5 +1,5 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildCustomShoutrrrUrl(config: Extract<NotificationConfig, { type: "custom" }>): string {
export const buildCustomShoutrrrUrl = (config: Extract<NotificationConfig, { type: "custom" }>) => {
return config.shoutrrrUrl;
}
};

View file

@ -1,6 +1,6 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildDiscordShoutrrrUrl(config: Extract<NotificationConfig, { type: "discord" }>): string {
export const buildDiscordShoutrrrUrl = (config: Extract<NotificationConfig, { type: "discord" }>) => {
const url = new URL(config.webhookUrl);
const pathParts = url.pathname.split("/").filter(Boolean);
@ -28,4 +28,4 @@ export function buildDiscordShoutrrrUrl(config: Extract<NotificationConfig, { ty
}
return shoutrrrUrl;
}
};

View file

@ -1,6 +1,6 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildEmailShoutrrrUrl(config: Extract<NotificationConfig, { type: "email" }>): string {
export const buildEmailShoutrrrUrl = (config: Extract<NotificationConfig, { type: "email" }>) => {
const auth =
config.username && config.password
? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@`
@ -10,4 +10,4 @@ export function buildEmailShoutrrrUrl(config: Extract<NotificationConfig, { type
const useStartTLS = config.useTLS ? "yes" : "no";
return `smtp://${auth}${host}/?from=${encodeURIComponent(config.from)}&to=${toRecipients}&starttls=${useStartTLS}`;
}
};

View file

@ -0,0 +1,50 @@
import type { NotificationConfig } from "~/schemas/notifications";
export const buildGenericShoutrrrUrl = (config: Extract<NotificationConfig, { type: "generic" }>) => {
const targetUrl = new URL(config.url);
const shoutrrrUrl = new URL(`generic://${targetUrl.host}${targetUrl.pathname}`);
for (const [key, value] of targetUrl.searchParams.entries()) {
const reservedKeys = ["contenttype", "disabletls", "messagekey", "method", "template", "title", "titlekey"];
if (reservedKeys.includes(key.toLowerCase())) {
shoutrrrUrl.searchParams.append(`_${key}`, value);
} else {
shoutrrrUrl.searchParams.append(key, value);
}
}
if (targetUrl.protocol === "http:") {
shoutrrrUrl.searchParams.append("disabletls", "yes");
}
if (config.method) {
shoutrrrUrl.searchParams.append("method", config.method);
}
if (config.contentType) {
shoutrrrUrl.searchParams.append("contenttype", config.contentType);
}
if (config.useJson) {
shoutrrrUrl.searchParams.append("template", "json");
}
if (config.titleKey) {
shoutrrrUrl.searchParams.append("titlekey", config.titleKey);
}
if (config.messageKey) {
shoutrrrUrl.searchParams.append("messagekey", config.messageKey);
}
if (config.headers) {
for (const header of config.headers) {
const [key, ...valueParts] = header.split(":");
if (key && valueParts.length > 0) {
shoutrrrUrl.searchParams.append(`@${key.trim()}`, valueParts.join(":").trim());
}
}
}
return shoutrrrUrl.toString();
};

View file

@ -1,6 +1,6 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildGotifyShoutrrrUrl(config: Extract<NotificationConfig, { type: "gotify" }>): string {
export const buildGotifyShoutrrrUrl = (config: Extract<NotificationConfig, { type: "gotify" }>) => {
const url = new URL(config.serverUrl);
const hostname = url.hostname;
const port = url.port ? `:${url.port}` : "";
@ -13,4 +13,4 @@ export function buildGotifyShoutrrrUrl(config: Extract<NotificationConfig, { typ
}
return shoutrrrUrl;
}
};

View file

@ -6,9 +6,10 @@ import { buildGotifyShoutrrrUrl } from "./gotify";
import { buildNtfyShoutrrrUrl } from "./ntfy";
import { buildPushoverShoutrrrUrl } from "./pushover";
import { buildTelegramShoutrrrUrl } from "./telegram";
import { buildGenericShoutrrrUrl } from "./generic";
import { buildCustomShoutrrrUrl } from "./custom";
export function buildShoutrrrUrl(config: NotificationConfig): string {
export const buildShoutrrrUrl = (config: NotificationConfig) => {
switch (config.type) {
case "email":
return buildEmailShoutrrrUrl(config);
@ -24,12 +25,13 @@ export function buildShoutrrrUrl(config: NotificationConfig): string {
return buildPushoverShoutrrrUrl(config);
case "telegram":
return buildTelegramShoutrrrUrl(config);
case "generic":
return buildGenericShoutrrrUrl(config);
case "custom":
return buildCustomShoutrrrUrl(config);
default: {
// TypeScript exhaustiveness check
const _exhaustive: never = config;
throw new Error(`Unsupported notification type: ${(_exhaustive as NotificationConfig).type}`);
}
}
}
};

View file

@ -1,6 +1,6 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildNtfyShoutrrrUrl(config: Extract<NotificationConfig, { type: "ntfy" }>): string {
export const buildNtfyShoutrrrUrl = (config: Extract<NotificationConfig, { type: "ntfy" }>) => {
let shoutrrrUrl: string;
const params = new URLSearchParams();
@ -38,4 +38,4 @@ export function buildNtfyShoutrrrUrl(config: Extract<NotificationConfig, { type:
}
return shoutrrrUrl;
}
};

View file

@ -1,6 +1,6 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildPushoverShoutrrrUrl(config: Extract<NotificationConfig, { type: "pushover" }>): string {
export const buildPushoverShoutrrrUrl = (config: Extract<NotificationConfig, { type: "pushover" }>) => {
const params = new URLSearchParams();
if (config.devices) {
@ -19,4 +19,4 @@ export function buildPushoverShoutrrrUrl(config: Extract<NotificationConfig, { t
}
return shoutrrrUrl;
}
};

View file

@ -1,6 +1,6 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildSlackShoutrrrUrl(config: Extract<NotificationConfig, { type: "slack" }>): string {
export const buildSlackShoutrrrUrl = (config: Extract<NotificationConfig, { type: "slack" }>) => {
const url = new URL(config.webhookUrl);
const pathParts = url.pathname.split("/").filter(Boolean);
@ -28,4 +28,4 @@ export function buildSlackShoutrrrUrl(config: Extract<NotificationConfig, { type
}
return shoutrrrUrl;
}
};

View file

@ -1,5 +1,5 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildTelegramShoutrrrUrl(config: Extract<NotificationConfig, { type: "telegram" }>): string {
export const buildTelegramShoutrrrUrl = (config: Extract<NotificationConfig, { type: "telegram" }>) => {
return `telegram://${config.botToken}@telegram?channels=${config.chatId}`;
}
};

View file

@ -71,6 +71,8 @@ async function encryptSensitiveFields(config: NotificationConfig): Promise<Notif
...config,
botToken: await cryptoUtils.sealSecret(config.botToken),
};
case "generic":
return config;
case "custom":
return {
...config,
@ -118,6 +120,8 @@ async function decryptSensitiveFields(config: NotificationConfig): Promise<Notif
...config,
botToken: await cryptoUtils.resolveSecret(config.botToken),
};
case "generic":
return config;
case "custom":
return {
...config,