Merge branch 'dev' into feature/Integrate-Azure-Blob

# Conflicts:
#	src/db/migrations/meta/0063_snapshot.json
#	src/db/migrations/meta/_journal.json
This commit is contained in:
Charles GTE
2026-06-20 14:56:41 +02:00
15 changed files with 3220 additions and 11 deletions
@@ -0,0 +1 @@
ALTER TYPE "public"."provider_kind" ADD VALUE 'pushover';
+6 -5
View File
@@ -1,6 +1,6 @@
{
"id": "30a01603-826b-4d38-acfe-1689c7d85496",
"prevId": "e6d15bce-96b6-468c-8853-08efe5a18ecd",
"id": "93d81732-8e94-4c2e-be9d-79c7bacc398e",
"prevId": "679bcdd7-cf48-43d5-965d-0814b49196e1",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -2843,7 +2843,9 @@
"gotify",
"ntfy",
"webhook",
"nextcloud"
"nextcloud",
"teams",
"pushover"
]
},
"public.event_kind": {
@@ -2874,8 +2876,7 @@
"values": [
"local",
"s3",
"google-drive",
"blob"
"google-drive"
]
},
"public.backup_storage_status": {
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -446,8 +446,15 @@
{
"idx": 63,
"version": "7",
"when": 1781898343882,
"tag": "0063_steady_randall_flagg",
"when": 1781854323074,
"tag": "0063_open_proudstar",
"breakpoints": true
},
{
"idx": 64,
"version": "7",
"when": 1781960184847,
"tag": "0064_orange_richard_fisk",
"breakpoints": true
}
]
+1 -1
View File
@@ -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', 'teams']);
export const providerKindEnum = pgEnum('provider_kind', ['slack', 'smtp', 'discord', 'telegram', 'gotify', 'ntfy', 'webhook', 'nextcloud', 'teams', '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 { BlobChannelConfigSchema } from "@/features/channel/storages/Blob.schema";
@@ -59,6 +60,10 @@ export const NotificationChannelFormSchema = z.discriminatedUnion("provider", [
provider: z.literal("teams"),
config: TeamsChannelConfigSchema,
}),
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";
@@ -100,6 +103,8 @@ export const renderChannelForm = (provider: string | undefined, form: UseFormRet
return <NotifierNextcloudForm form={form}/>;
case "teams":
return <NotifierTeamsForm form={form}/>;
case "pushover":
return <NotifierPushoverForm form={form}/>;
case "s3":
return <StorageS3Form form={form}/>
case "google-drive":
@@ -14,7 +14,8 @@ 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: "teams", label: "Microsoft Teams", icon: MSTeamsIcon}
{value: "teams", label: "Microsoft Teams", icon: MSTeamsIcon},
{value: "pushover", label: "Pushover", icon: PushoverIcon},
]
@@ -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";
import {sendTeams} from "@/features/channel/notifications/teams"
const handlers: Record<
@@ -23,6 +24,7 @@ const handlers: Record<
webhook: sendWebhook,
nextcloud: sendNextcloud,
teams: sendTeams,
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 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
const payloadError = notificationLog.payload?.error;
const troubleshooting = getTroubleshootingForError(payloadError);
const dispatchError = !notificationLog.success ? notificationLog.error : null;
const troubleshooting = getTroubleshootingForError(payloadError || dispatchError);
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -131,6 +132,24 @@ export const NotificationLogModal = ({
)}
</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" />
{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 {
success: boolean;