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";
|
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', {
|
export const notificationChannel = pgTable('notification_channel', {
|
||||||
id: uuid("id").defaultRandom().primaryKey(),
|
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 {NtfyChannelConfigSchema} from "@/features/channel/notifications/ntfy.schema";
|
||||||
import {WebhookChannelConfigSchema} from "@/features/channel/notifications/webhook.schema";
|
import {WebhookChannelConfigSchema} from "@/features/channel/notifications/webhook.schema";
|
||||||
import {NextcloudChannelConfigSchema} from "@/features/channel/notifications/nextcloud.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 {S3ChannelConfigSchema} from "@/features/channel/storages/s3.schema";
|
||||||
import {GoogleDriveChannelConfigSchema} from "@/features/channel/storages/google-drive/google-drive.schema";
|
import {GoogleDriveChannelConfigSchema} from "@/features/channel/storages/google-drive/google-drive.schema";
|
||||||
import {LocalChannelConfigSchema} from "@/features/channel/storages/local.schema";
|
import {LocalChannelConfigSchema} from "@/features/channel/storages/local.schema";
|
||||||
@@ -53,6 +54,10 @@ export const NotificationChannelFormSchema = z.discriminatedUnion("provider", [
|
|||||||
provider: z.literal("nextcloud"),
|
provider: z.literal("nextcloud"),
|
||||||
config: NextcloudChannelConfigSchema,
|
config: NextcloudChannelConfigSchema,
|
||||||
}),
|
}),
|
||||||
|
BaseChannelFormSchema.extend({
|
||||||
|
provider: z.literal("pushover"),
|
||||||
|
config: PushoverChannelConfigSchema,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const StorageChannelFormSchema = z.discriminatedUnion("provider", [
|
export const StorageChannelFormSchema = z.discriminatedUnion("provider", [
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
NotifierNextcloudForm
|
NotifierNextcloudForm
|
||||||
} from "@/features/channel/notifications/nextcloud.form";
|
} from "@/features/channel/notifications/nextcloud.form";
|
||||||
|
import {
|
||||||
|
NotifierPushoverForm
|
||||||
|
} from "@/features/channel/notifications/pushover.form";
|
||||||
import {
|
import {
|
||||||
notificationProviders,
|
notificationProviders,
|
||||||
} from "@/features/channel/channels-notification-helper";
|
} from "@/features/channel/channels-notification-helper";
|
||||||
@@ -92,6 +95,8 @@ export const renderChannelForm = (provider: string | undefined, form: UseFormRet
|
|||||||
return <NotifierWebhookForm form={form}/>;
|
return <NotifierWebhookForm form={form}/>;
|
||||||
case "nextcloud":
|
case "nextcloud":
|
||||||
return <NotifierNextcloudForm form={form}/>;
|
return <NotifierNextcloudForm form={form}/>;
|
||||||
|
case "pushover":
|
||||||
|
return <NotifierPushoverForm form={form}/>;
|
||||||
case "s3":
|
case "s3":
|
||||||
return <StorageS3Form form={form}/>
|
return <StorageS3Form form={form}/>
|
||||||
case "google-drive":
|
case "google-drive":
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const notificationProviders: ProviderIconTypes[] = [
|
|||||||
{value: "ntfy", label: "ntfy.sh", icon: NtfyIcon},
|
{value: "ntfy", label: "ntfy.sh", icon: NtfyIcon},
|
||||||
{value: "webhook", label: "Webhook", icon: WebhookIcon},
|
{value: "webhook", label: "Webhook", icon: WebhookIcon},
|
||||||
{value: "nextcloud", label: "Nextcloud Talk", icon: NextcloudIcon},
|
{value: "nextcloud", label: "Nextcloud Talk", icon: NextcloudIcon},
|
||||||
|
{value: "pushover", label: "Pushover", icon: PushoverIcon},
|
||||||
{value: "microsoft-teams", label: "Microsoft Teams", icon: MSTeamsIcon, preview: true}
|
{value: "microsoft-teams", label: "Microsoft Teams", icon: MSTeamsIcon, preview: true}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -556,3 +557,20 @@ export function NextcloudIcon(props: SVGProps<SVGSVGElement>) {
|
|||||||
</svg>
|
</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 {sendNtfy} from "@/features/channel/notifications/ntfy";
|
||||||
import {sendWebhook} from "@/features/channel/notifications/webhook";
|
import {sendWebhook} from "@/features/channel/notifications/webhook";
|
||||||
import {sendNextcloud} from "@/features/channel/notifications/nextcloud";
|
import {sendNextcloud} from "@/features/channel/notifications/nextcloud";
|
||||||
|
import {sendPushover} from "@/features/channel/notifications/pushover";
|
||||||
|
|
||||||
const handlers: Record<
|
const handlers: Record<
|
||||||
ProviderKind,
|
ProviderKind,
|
||||||
@@ -21,6 +22,7 @@ const handlers: Record<
|
|||||||
ntfy: sendNtfy,
|
ntfy: sendNtfy,
|
||||||
webhook: sendWebhook,
|
webhook: sendWebhook,
|
||||||
nextcloud: sendNextcloud,
|
nextcloud: sendNextcloud,
|
||||||
|
pushover: sendPushover,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function dispatchViaProvider(
|
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 {
|
export interface DispatchResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user