mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Added support for pushover notifications
This commit is contained in:
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."provider_kind" ADD VALUE 'pushover';
|
||||
@@ -7,7 +7,7 @@ import {z} from "zod";
|
||||
import {OrganizationInvitation} from "@/db/schema/05_invitation";
|
||||
|
||||
|
||||
export const providerKindEnum = pgEnum('provider_kind', ['slack', 'smtp', 'discord', 'telegram', 'gotify', 'ntfy', 'webhook', 'nextcloud']);
|
||||
export const providerKindEnum = pgEnum('provider_kind', ['slack', 'smtp', 'discord', 'telegram', 'gotify', 'ntfy', 'webhook', 'nextcloud', 'pushover']);
|
||||
|
||||
export const notificationChannel = pgTable('notification_channel', {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {GotifyChannelConfigSchema} from "@/features/channel/notifications/gotify
|
||||
import {NtfyChannelConfigSchema} from "@/features/channel/notifications/ntfy.schema";
|
||||
import {WebhookChannelConfigSchema} from "@/features/channel/notifications/webhook.schema";
|
||||
import {NextcloudChannelConfigSchema} from "@/features/channel/notifications/nextcloud.schema";
|
||||
import {PushoverChannelConfigSchema} from "@/features/channel/notifications/pushover.schema";
|
||||
import {S3ChannelConfigSchema} from "@/features/channel/storages/s3.schema";
|
||||
import {GoogleDriveChannelConfigSchema} from "@/features/channel/storages/google-drive/google-drive.schema";
|
||||
import {LocalChannelConfigSchema} from "@/features/channel/storages/local.schema";
|
||||
@@ -53,6 +54,10 @@ export const NotificationChannelFormSchema = z.discriminatedUnion("provider", [
|
||||
provider: z.literal("nextcloud"),
|
||||
config: NextcloudChannelConfigSchema,
|
||||
}),
|
||||
BaseChannelFormSchema.extend({
|
||||
provider: z.literal("pushover"),
|
||||
config: PushoverChannelConfigSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
export const StorageChannelFormSchema = z.discriminatedUnion("provider", [
|
||||
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
import {
|
||||
NotifierNextcloudForm
|
||||
} from "@/features/channel/notifications/nextcloud.form";
|
||||
import {
|
||||
NotifierPushoverForm
|
||||
} from "@/features/channel/notifications/pushover.form";
|
||||
import {
|
||||
notificationProviders,
|
||||
} from "@/features/channel/channels-notification-helper";
|
||||
@@ -92,6 +95,8 @@ export const renderChannelForm = (provider: string | undefined, form: UseFormRet
|
||||
return <NotifierWebhookForm form={form}/>;
|
||||
case "nextcloud":
|
||||
return <NotifierNextcloudForm form={form}/>;
|
||||
case "pushover":
|
||||
return <NotifierPushoverForm form={form}/>;
|
||||
case "s3":
|
||||
return <StorageS3Form form={form}/>
|
||||
case "google-drive":
|
||||
|
||||
@@ -14,6 +14,7 @@ export const notificationProviders: ProviderIconTypes[] = [
|
||||
{value: "ntfy", label: "ntfy.sh", icon: NtfyIcon},
|
||||
{value: "webhook", label: "Webhook", icon: WebhookIcon},
|
||||
{value: "nextcloud", label: "Nextcloud Talk", icon: NextcloudIcon},
|
||||
{value: "pushover", label: "Pushover", icon: PushoverIcon},
|
||||
{value: "microsoft-teams", label: "Microsoft Teams", icon: MSTeamsIcon, preview: true}
|
||||
]
|
||||
|
||||
@@ -555,4 +556,21 @@ export function NextcloudIcon(props: SVGProps<SVGSVGElement>) {
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PushoverIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
{...props}
|
||||
>
|
||||
<rect width="100" height="100" rx="22" fill="#249DF1"/>
|
||||
<path
|
||||
d="M28 72V28h16.5c5.5 0 9.8 1.3 12.8 3.8 3 2.5 4.5 6 4.5 10.4 0 4.5-1.5 8-4.5 10.5-3 2.5-7.3 3.8-12.8 3.8H38V72H28zm10-23.8h6c2.8 0 4.9-.6 6.3-1.9 1.4-1.3 2.1-3.1 2.1-5.4 0-2.3-.7-4-2.1-5.3-1.4-1.3-3.5-1.9-6.3-1.9H38v14.5z"
|
||||
fill="white"
|
||||
/>
|
||||
<circle cx="72" cy="68" r="8" fill="white" opacity="0.9"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {sendGotify} from "@/features/channel/notifications/gotify";
|
||||
import {sendNtfy} from "@/features/channel/notifications/ntfy";
|
||||
import {sendWebhook} from "@/features/channel/notifications/webhook";
|
||||
import {sendNextcloud} from "@/features/channel/notifications/nextcloud";
|
||||
import {sendPushover} from "@/features/channel/notifications/pushover";
|
||||
|
||||
const handlers: Record<
|
||||
ProviderKind,
|
||||
@@ -21,6 +22,7 @@ const handlers: Record<
|
||||
ntfy: sendNtfy,
|
||||
webhook: sendWebhook,
|
||||
nextcloud: sendNextcloud,
|
||||
pushover: sendPushover,
|
||||
};
|
||||
|
||||
export async function dispatchViaProvider(
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import {UseFormReturn} from "react-hook-form";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
|
||||
import {PUSHOVER_PRIORITIES} from "@/features/channel/notifications/pushover.schema";
|
||||
|
||||
type NotifierPushoverFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierPushoverForm = ({form}: NotifierPushoverFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.pushoverUserKey"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>User Key *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="Your 30-character Pushover user key"/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Found at the top of your Pushover dashboard at pushover.net.
|
||||
</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.pushoverApiToken"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>App API Token *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="Your 30-character application API token"/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create an application at pushover.net/apps to get an API token.
|
||||
</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Separator className="my-1"/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.pushoverPriority"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Message Priority</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value ?? "0"}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select priority"/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{PUSHOVER_PRIORITIES.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Emergency priority repeats every 60 seconds until acknowledged (max 1 hour).
|
||||
</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.pushoverDevice"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Device Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ""} placeholder="e.g. iphone (leave empty for all devices)"/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Target a specific registered device. Leave empty to send to all devices.
|
||||
</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const PUSHOVER_PRIORITIES = [
|
||||
{value: "-2", label: "Lowest — no sound, no vibration, no alert"},
|
||||
{value: "-1", label: "Low — quiet notification, no sound"},
|
||||
{value: "0", label: "Normal — default sound and alert"},
|
||||
{value: "1", label: "High — bypass quiet hours"},
|
||||
{value: "2", label: "Emergency — repeat until acknowledged"},
|
||||
] as const;
|
||||
|
||||
export const PushoverChannelConfigSchema = z.object({
|
||||
pushoverUserKey: z.string().min(30, "User key must be 30 characters").max(30, "User key must be 30 characters"),
|
||||
pushoverApiToken: z.string().min(30, "API token must be 30 characters").max(30, "API token must be 30 characters"),
|
||||
pushoverPriority: z.enum(["-2", "-1", "0", "1", "2"]).default("0"),
|
||||
pushoverDevice: z.string().max(25, "Device name must be at most 25 characters").optional().or(z.literal("")),
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type {EventPayload, DispatchResult} from '@/features/notifications/notifications.types';
|
||||
|
||||
export async function sendPushover(
|
||||
config: {
|
||||
pushoverUserKey: string;
|
||||
pushoverApiToken: string;
|
||||
pushoverPriority: string;
|
||||
pushoverDevice?: string;
|
||||
},
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
const {pushoverUserKey, pushoverApiToken, pushoverDevice} = config;
|
||||
const priority = parseInt(config.pushoverPriority ?? "0", 10);
|
||||
|
||||
const body: Record<string, string | number> = {
|
||||
token: pushoverApiToken,
|
||||
user: pushoverUserKey,
|
||||
title: `[${payload.level.toUpperCase()}] ${payload.title}`,
|
||||
message: payload.data
|
||||
? `${payload.message}\n\nData:\n${JSON.stringify(payload.data, null, 2)}`
|
||||
: payload.message,
|
||||
priority,
|
||||
};
|
||||
|
||||
if (pushoverDevice) {
|
||||
body.device = pushoverDevice;
|
||||
}
|
||||
|
||||
// Emergency priority requires retry + expire
|
||||
if (priority === 2) {
|
||||
body.retry = 60;
|
||||
body.expire = 3600;
|
||||
}
|
||||
|
||||
const res = await fetch("https://api.pushover.net/1/messages.json", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const json = await res.json().catch(() => null);
|
||||
|
||||
if (!res.ok) {
|
||||
const errors = json?.errors?.join(", ") ?? res.statusText;
|
||||
throw new Error(`Pushover error ${res.status}: ${errors}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "pushover",
|
||||
message: "Sent via Pushover",
|
||||
response: json,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ProviderKind = 'slack' | 'smtp' | 'discord' | 'telegram' | 'gotify' | 'ntfy' | 'webhook' | 'nextcloud';
|
||||
export type ProviderKind = 'slack' | 'smtp' | 'discord' | 'telegram' | 'gotify' | 'ntfy' | 'webhook' | 'nextcloud' | 'pushover';
|
||||
|
||||
export interface DispatchResult {
|
||||
success: boolean;
|
||||
|
||||
Reference in New Issue
Block a user