mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Merge pull request #325 from pcrosthwaite/feature/pushover-notifications
Feature/pushover notifications
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "public"."provider_kind" ADD VALUE 'pushover';
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -442,6 +442,13 @@
|
|||||||
"when": 1780934519843,
|
"when": 1780934519843,
|
||||||
"tag": "0062_futuristic_dragon_lord",
|
"tag": "0062_futuristic_dragon_lord",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 63,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781854323074,
|
||||||
|
"tag": "0063_open_proudstar",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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', 'teams']);
|
export const providerKindEnum = pgEnum('provider_kind', ['slack', 'smtp', 'discord', 'telegram', 'gotify', 'ntfy', 'webhook', 'nextcloud', 'teams', '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";
|
||||||
@@ -58,6 +59,10 @@ export const NotificationChannelFormSchema = z.discriminatedUnion("provider", [
|
|||||||
provider: z.literal("teams"),
|
provider: z.literal("teams"),
|
||||||
config: TeamsChannelConfigSchema,
|
config: TeamsChannelConfigSchema,
|
||||||
}),
|
}),
|
||||||
|
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";
|
||||||
@@ -97,6 +100,8 @@ export const renderChannelForm = (provider: string | undefined, form: UseFormRet
|
|||||||
return <NotifierNextcloudForm form={form}/>;
|
return <NotifierNextcloudForm form={form}/>;
|
||||||
case "teams":
|
case "teams":
|
||||||
return <NotifierTeamsForm form={form}/>;
|
return <NotifierTeamsForm 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,7 +14,8 @@ 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: "teams", label: "Microsoft Teams", icon: MSTeamsIcon}
|
{value: "teams", label: "Microsoft Teams", icon: MSTeamsIcon},
|
||||||
|
{value: "pushover", label: "Pushover", icon: PushoverIcon},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -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";
|
||||||
import {sendTeams} from "@/features/channel/notifications/teams"
|
import {sendTeams} from "@/features/channel/notifications/teams"
|
||||||
|
|
||||||
const handlers: Record<
|
const handlers: Record<
|
||||||
@@ -23,6 +24,7 @@ const handlers: Record<
|
|||||||
webhook: sendWebhook,
|
webhook: sendWebhook,
|
||||||
nextcloud: sendNextcloud,
|
nextcloud: sendNextcloud,
|
||||||
teams: sendTeams,
|
teams: sendTeams,
|
||||||
|
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 className="w-full">
|
||||||
|
<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,65 @@
|
|||||||
|
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 rawTitle = `[${payload.level.toUpperCase()}] ${payload.title}`;
|
||||||
|
const rawMessage = payload.data
|
||||||
|
? `${payload.message}\n\nData:\n${JSON.stringify(payload.data)}`
|
||||||
|
: payload.message;
|
||||||
|
|
||||||
|
const body: Record<string, string | number> = {
|
||||||
|
token: pushoverApiToken,
|
||||||
|
user: pushoverUserKey,
|
||||||
|
title: rawTitle.length > 250 ? rawTitle.slice(0, 249) + "…" : rawTitle,
|
||||||
|
message: rawMessage.length > 1024 ? rawMessage.slice(0, 1023) + "…" : rawMessage,
|
||||||
|
priority,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pushoverDevice) {
|
||||||
|
body.device = pushoverDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emergency priority requires retry + expire
|
||||||
|
if (priority === 2) {
|
||||||
|
body.retry = 60;
|
||||||
|
body.expire = 3600;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch("https://api.pushover.net/1/messages.json", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -79,7 +79,8 @@ export const NotificationLogModal = ({
|
|||||||
|
|
||||||
// @ts-expect-error — payload type is not fully typed yet
|
// @ts-expect-error — payload type is not fully typed yet
|
||||||
const payloadError = notificationLog.payload?.error;
|
const payloadError = notificationLog.payload?.error;
|
||||||
const troubleshooting = getTroubleshootingForError(payloadError);
|
const dispatchError = !notificationLog.success ? notificationLog.error : null;
|
||||||
|
const troubleshooting = getTroubleshootingForError(payloadError || dispatchError);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
@@ -131,6 +132,24 @@ export const NotificationLogModal = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{dispatchError && (
|
||||||
|
<>
|
||||||
|
<div className="w-px h-6 bg-border mx-auto -my-4 relative z-0" />
|
||||||
|
<div className="relative border border-destructive/30 rounded-xl bg-card shadow-sm flex flex-col">
|
||||||
|
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-destructive rounded-full border-2 border-background z-10" />
|
||||||
|
<div className="bg-destructive/10 border-b border-destructive/10 px-4 py-2.5 flex items-center gap-2 rounded-t-xl">
|
||||||
|
<div className="p-1 bg-destructive/20 rounded-md">
|
||||||
|
<AlertCircle className="w-4 h-4 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-sm text-destructive">Dispatch Error</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<span className="text-sm font-mono text-destructive break-all">{dispatchError}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="w-px h-6 bg-border mx-auto -my-4 relative z-0" />
|
<div className="w-px h-6 bg-border mx-auto -my-4 relative z-0" />
|
||||||
|
|
||||||
{notificationLog.payload &&
|
{notificationLog.payload &&
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type ProviderKind = 'slack' | 'smtp' | 'discord' | 'telegram' | 'gotify' | 'ntfy' | 'webhook' | 'nextcloud' | 'teams';
|
export type ProviderKind = 'slack' | 'smtp' | 'discord' | 'telegram' | 'gotify' | 'ntfy' | 'webhook' | 'nextcloud' | 'teams' | 'pushover';
|
||||||
|
|
||||||
export interface DispatchResult {
|
export interface DispatchResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user